diff --git a/.env.custom b/.env.custom index b35cf9192..6039cede5 100644 --- a/.env.custom +++ b/.env.custom @@ -1 +1 @@ -APPLICATION_VERSION=1.36.4 \ No newline at end of file +APPLICATION_VERSION=1.45.3 \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index d3c2987cb..a8dc812ac 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,11 +1,9 @@ { - "extends": [ - "next", - "prettier", - "plugin:prettier/recommended", - "plugin:storybook/recommended" - ], + "extends": ["next", "prettier", "plugin:prettier/recommended", "plugin:storybook/recommended"], "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": ["./tsconfig.json"] + }, "rules": { "@next/next/no-img-element": "off", "@next/next/google-font-display": "off", @@ -13,7 +11,9 @@ "@next/next/no-page-custom-font": "off", "unused-imports/no-unused-imports-ts": "off", "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/await-thenable": "error", "no-constant-condition": "warn", + "no-unused-vars": ["off", { "varsIgnorePattern": "^_" }], "react-hooks/exhaustive-deps": [ "warn", { @@ -25,14 +25,6 @@ "jsx-quotes": ["error", "prefer-double"], "react/jsx-curly-brace-presence": ["off", { "props": "never", "children": "never" }] }, - "ignorePatterns": [ - "node_modules/", - ".next/", - ".github/" - ], - "plugins": [ - "unused-imports", - "@typescript-eslint", - "no-only-tests" - ] + "ignorePatterns": ["node_modules/", ".next/", ".github/", "cypress/", "src/types/contracts/"], + "plugins": ["unused-imports", "@typescript-eslint", "no-only-tests"] } diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 023c76865..d186bb29e 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,7 +1,7 @@ --- name: Bug report about: Create an issue to fix a bug -labels: ["bug"] +type: 'bug' --- +
+

Font Test Drive

+ + +
  +
+
+ +
+

Generated by IcoMoon

+
+ + + + diff --git a/apps/mobile/resources/icons/safe-icons/fonts/safe-icons.svg b/apps/mobile/resources/icons/safe-icons/fonts/safe-icons.svg new file mode 100644 index 000000000..38a451308 --- /dev/null +++ b/apps/mobile/resources/icons/safe-icons/fonts/safe-icons.svg @@ -0,0 +1,112 @@ + + + +Generated by IcoMoon + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/resources/icons/safe-icons/fonts/safe-icons.ttf b/apps/mobile/resources/icons/safe-icons/fonts/safe-icons.ttf new file mode 100644 index 000000000..95dd98805 Binary files /dev/null and b/apps/mobile/resources/icons/safe-icons/fonts/safe-icons.ttf differ diff --git a/apps/mobile/resources/icons/safe-icons/fonts/safe-icons.woff b/apps/mobile/resources/icons/safe-icons/fonts/safe-icons.woff new file mode 100644 index 000000000..13341da03 Binary files /dev/null and b/apps/mobile/resources/icons/safe-icons/fonts/safe-icons.woff differ diff --git a/apps/mobile/resources/icons/safe-icons/selection.json b/apps/mobile/resources/icons/safe-icons/selection.json new file mode 100644 index 000000000..6bc8add89 --- /dev/null +++ b/apps/mobile/resources/icons/safe-icons/selection.json @@ -0,0 +1,2451 @@ +{ + "IcoMoonType": "selection", + "icons": [ + { + "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" + ], + "attrs": [{ "fill": "rgb(255, 95, 114)" }], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["block"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "f": 4 }] } + }, + "attrs": [{ "fill": "rgb(255, 95, 114)" }], + "properties": { "order": 121, "id": 103, "name": "block", "prevSize": 32, "code": 59648 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["alert-triangle"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "f": 4 }, { "f": 4 }, { "f": 4 }] } + }, + "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 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["alert"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "f": 4 }, { "f": 4 }, { "f": 4 }] } + }, + "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, + "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", + "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": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["info"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 118, "id": 100, "name": "info", "prevSize": 32, "code": 59651 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["question"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 117, "id": 99, "name": "question", "prevSize": 32, "code": 59652 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["points"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 116, "id": 98, "name": "points", "prevSize": 32, "code": 59653 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["code-blocks"], + "colorPermutations": { + "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] + } + }, + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], + "properties": { "order": 115, "id": 97, "name": "code-blocks", "prevSize": 32, "code": 59654 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["hardware"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 122, "id": 96, "name": "hardware", "prevSize": 32, "code": 59655 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["keystone"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}, {}, {}], + "properties": { "order": 123, "id": 95, "name": "keystone", "prevSize": 32, "code": 59656 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["ledger"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}, {}], + "properties": { "order": 124, "id": 94, "name": "ledger", "prevSize": 32, "code": 59657 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["seed"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 114, "id": 93, "name": "seed", "prevSize": 32, "code": 59658 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["key"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + }, + "attrs": [ + { + "fill": "none", + "strokeLinejoin": "round", + "strokeLinecap": "round", + "strokeMiterlimit": "4", + "strokeWidth": 85.33333333333333 + } + ], + "properties": { "order": 141, "id": 92, "name": "key", "prevSize": 32, "code": 59659 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["dapp-logo"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 112, "id": 91, "name": "dapp-logo", "prevSize": 32, "code": 59660 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["double-arrow"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 111, "id": 90, "name": "double-arrow", "prevSize": 32, "code": 59661 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["arrow-sort"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 110, "id": 89, "name": "arrow-sort", "prevSize": 32, "code": 59662 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["dropdown-arrow-small"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 109, "id": 88, "name": "dropdown-arrow-small", "prevSize": 32, "code": 59663 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["options-vertical"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 108, "id": 87, "name": "options-vertical", "prevSize": 32, "code": 59664 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["options-horizontal"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 107, "id": 86, "name": "options-horizontal", "prevSize": 32, "code": 59665 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": true, + "grid": 0, + "tags": ["check-oulined"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, { "s": 0 }] } + }, + "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 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": true, + "grid": 0, + "tags": ["check"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, { "s": 0 }] } + }, + "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 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["check-filled"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 104, "id": 83, "name": "check-filled", "prevSize": 32, "code": 59668 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["arrow-down-1"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 103, "id": 82, "name": "arrow-down-1", "prevSize": 32, "code": 59669 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["arrow-down"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + }, + "attrs": [ + { + "strokeLinejoin": "round", + "strokeLinecap": "round", + "strokeMiterlimit": "4", + "strokeWidth": 42.666666666666664 + } + ], + "properties": { "order": 135, "id": 81, "name": "arrow-down", "prevSize": 32, "code": 59670 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["arrow-up"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + }, + "attrs": [ + { + "strokeLinejoin": "round", + "strokeLinecap": "round", + "strokeMiterlimit": "4", + "strokeWidth": 42.666666666666664 + } + ], + "properties": { "order": 136, "id": 80, "name": "arrow-up", "prevSize": 32, "code": 59671 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["arrow-left"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + }, + "attrs": [ + { + "strokeLinejoin": "round", + "strokeLinecap": "round", + "strokeMiterlimit": "4", + "strokeWidth": 42.666666666666664 + } + ], + "properties": { "order": 138, "id": 79, "name": "arrow-left", "prevSize": 32, "code": 59672 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["arrow-right"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + }, + "attrs": [ + { + "strokeLinejoin": "round", + "strokeLinecap": "round", + "strokeMiterlimit": "4", + "strokeWidth": 42.666666666666664 + } + ], + "properties": { "order": 137, "id": 78, "name": "arrow-right", "prevSize": 32, "code": 59673 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["tag"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 3 }, { "s": 3 }] } + }, + "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 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["camera"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 100, "id": 76, "name": "camera", "prevSize": 32, "code": 59675 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["element-drag"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}, {}], + "properties": { "order": 99, "id": 75, "name": "element-drag", "prevSize": 32, "code": 59676 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-partial-fill"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 98, "id": 74, "name": "transaction-partial-fill", "prevSize": 32, "code": 59677 }, + "setIdx": 0, + "setId": 2, + "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)" } + ], + "isMulticolor": true, + "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] + }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["check-notifications"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 96, "id": 72, "name": "check-notifications", "prevSize": 32, "code": 59684 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["qr-code-1"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "properties": { "order": 95, "id": 71, "name": "qr-code-1", "prevSize": 32, "code": 59685 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["scan-1"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}], + "properties": { "order": 94, "id": 70, "name": "scan-1", "prevSize": 32, "code": 59686 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["shield-crossed"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }, { "s": 0 }, { "s": 0 }] } + }, + "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 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["shield"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 92, "id": 68, "name": "shield", "prevSize": 32, "code": 59688 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["clock"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 91, "id": 67, "name": "clock", "prevSize": 32, "code": 59689 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["update"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 90, "id": 66, "name": "update", "prevSize": 32, "code": 59690 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["repeat"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 89, "id": 65, "name": "repeat", "prevSize": 32, "code": 59691 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["download"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 88, "id": 64, "name": "download", "prevSize": 32, "code": 59692 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["upload"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 87, "id": 63, "name": "upload", "prevSize": 32, "code": 59693 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["qr-code"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "properties": { "order": 86, "id": 62, "name": "qr-code", "prevSize": 32, "code": 59694 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["scan"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}], + "properties": { "order": 85, "id": 61, "name": "scan", "prevSize": 32, "code": 59695 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["eye-n"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 84, "id": 60, "name": "eye-n", "prevSize": 32, "code": 59696 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["eye-off"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 83, "id": 59, "name": "eye-off", "prevSize": 32, "code": 59697 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["unlock"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 82, "id": 58, "name": "unlock", "prevSize": 32, "code": 59698 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["lock"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 81, "id": 57, "name": "lock", "prevSize": 32, "code": 59699 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["replace-owner"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 80, "id": 56, "name": "replace-owner", "prevSize": 32, "code": 59700 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["edit-owner"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 79, "id": 55, "name": "edit-owner", "prevSize": 32, "code": 59701 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["add-owner"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 78, "id": 54, "name": "add-owner", "prevSize": 32, "code": 59702 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["send-to"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 77, "id": 53, "name": "send-to", "prevSize": 32, "code": 59703 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["owners"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 76, "id": 52, "name": "owners", "prevSize": 32, "code": 59704 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["link"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 75, "id": 51, "name": "link", "prevSize": 32, "code": 59705 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["share"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 74, "id": 50, "name": "share", "prevSize": 32, "code": 59706 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["external-link"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 73, "id": 49, "name": "external-link", "prevSize": 32, "code": 59707 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["export"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }, { "s": 0 }, { "s": 0 }] } + }, + "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 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["paste"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 71, "id": 47, "name": "paste", "prevSize": 32, "code": 59709 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["copy"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 70, "id": 46, "name": "copy", "prevSize": 32, "code": 59710 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["sign"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 69, "id": 45, "name": "sign", "prevSize": 32, "code": 59711 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["document"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 68, "id": 44, "name": "document", "prevSize": 32, "code": 59712 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["file"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 67, "id": 43, "name": "file", "prevSize": 32, "code": 59713 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["search"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 66, "id": 42, "name": "search", "prevSize": 32, "code": 59714 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["edit"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 65, "id": 41, "name": "edit", "prevSize": 32, "code": 59715 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["delete"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 64, "id": 40, "name": "delete", "prevSize": 32, "code": 59716 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["close-outlined"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 63, "id": 39, "name": "close-outlined", "prevSize": 32, "code": 59717 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["close-filled"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 62, "id": 38, "name": "close-filled", "prevSize": 32, "code": 59718 }, + "setIdx": 0, + "setId": 2, + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["close"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + }, + "attrs": [ + { + "strokeLinejoin": "miter", + "strokeLinecap": "butt", + "strokeMiterlimit": "4", + "strokeWidth": 42.666666666666664 + } + ], + "properties": { "order": 134, "id": 37, "name": "close", "prevSize": 32, "code": 59719 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["plus-outlined"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 60, "id": 36, "name": "plus-outlined", "prevSize": 32, "code": 59720 }, + "setIdx": 0, + "setId": 2, + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["plus-filled"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 59, "id": 35, "name": "plus-filled", "prevSize": 32, "code": 59721 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 68 + }, + { + "icon": { + "paths": [ + "M503.019 170.667c24.802 0 44.911 20.108 44.911 44.912v592.844c0 24.802-20.109 44.911-44.911 44.911-24.806 0-44.915-20.109-44.915-44.911v-592.844c0-24.804 20.109-44.912 44.915-44.912z", + "M853.333 503.019c0 24.802-20.109 44.911-44.911 44.911h-592.844c-24.804 0-44.912-20.109-44.912-44.911 0-24.806 20.108-44.915 44.912-44.915h592.844c24.802 0 44.911 20.109 44.911 44.915z" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["plus"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 58, "id": 34, "name": "plus", "prevSize": 32, "code": 59722 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 69 + }, + { + "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" + ], + "attrs": [{ "fill": "rgb(255, 214, 0)" }, { "fill": "rgb(0, 0, 0)" }], + "isMulticolor": true, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-batch"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "f": 2 }, { "f": 0 }] } + }, + "attrs": [{ "fill": "rgb(255, 214, 0)" }, { "fill": "rgb(0, 0, 0)" }], + "properties": { + "order": 146, + "id": 33, + "name": "transaction-batch", + "prevSize": 32, + "code": 59723, + "codes": [59723, 59724] + }, + "setIdx": 0, + "setId": 2, + "iconIdx": 70 + }, + { + "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" + ], + "attrs": [{}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["blocks-1"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}], + "properties": { "order": 56, "id": 32, "name": "blocks-1", "prevSize": 32, "code": 59725 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 71 + }, + { + "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-1"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 55, "id": 31, "name": "rows-1", "prevSize": 32, "code": 59726 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 72 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["batch"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 54, "id": 30, "name": "batch", "prevSize": 32, "code": 59727 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 73 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["filter"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 53, "id": 29, "name": "filter", "prevSize": 32, "code": 59728 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 74 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["bookmark-filled"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 52, "id": 28, "name": "bookmark-filled", "prevSize": 32, "code": 59729 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 75 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["bookmark"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 51, "id": 27, "name": "bookmark", "prevSize": 32, "code": 59730 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 76 + }, + { + "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" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-recovery"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 50, "id": 26, "name": "transaction-recovery", "prevSize": 32, "code": 59731 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 77 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-change-settings"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 49, "id": 25, "name": "transaction-change-settings", "prevSize": 32, "code": 59732 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 78 + }, + { + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-contract"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 48, "id": 24, "name": "transaction-contract", "prevSize": 32, "code": 59733 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 79 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-execute"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 47, "id": 23, "name": "transaction-execute", "prevSize": 32, "code": 59734 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 80 + }, + { + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-stake"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 46, "id": 22, "name": "transaction-stake", "prevSize": 32, "code": 59735 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 81 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-swap"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 45, "id": 21, "name": "transaction-swap", "prevSize": 32, "code": 59736 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 82 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-outgoing"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 44, "id": 20, "name": "transaction-outgoing", "prevSize": 32, "code": 59737 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 83 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transaction-incoming"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 43, "id": 19, "name": "transaction-incoming", "prevSize": 32, "code": 59738 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 84 + }, + { + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["mobile"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 42, "id": 18, "name": "mobile", "prevSize": 32, "code": 59739 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 85 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["wallet"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 41, "id": 17, "name": "wallet", "prevSize": 32, "code": 59740 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 86 + }, + { + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["appearance"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + }, + "attrs": [ + { + "fill": "none", + "strokeLinejoin": "round", + "strokeLinecap": "round", + "strokeMiterlimit": "4", + "strokeWidth": 85.33333333333333 + } + ], + "properties": { "order": 142, "id": 16, "name": "appearance", "prevSize": 32, "code": 59741 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 87 + }, + { + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["experimental"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }, { "s": 0 }, { "s": 0 }, { "s": 0 }] } + }, + "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 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 88 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["desktop"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 38, "id": 14, "name": "desktop", "prevSize": 32, "code": 59743 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 89 + }, + { + "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" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["safe"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 24, "id": 13, "name": "safe", "prevSize": 32, "code": 59744 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 90 + }, + { + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["bell"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }, { "s": 0 }] } + }, + "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 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 91 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["lightbulb"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 26, "id": 11, "name": "lightbulb", "prevSize": 32, "code": 59746 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 92 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["what-is-new"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 27, "id": 10, "name": "what-is-new", "prevSize": 32, "code": 59747 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 93 + }, + { + "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" + ], + "attrs": [{}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["blocks"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}], + "properties": { "order": 28, "id": 9, "name": "blocks", "prevSize": 32, "code": 59748 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 94 + }, + { + "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": { "11611631671181918125521401255951141": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 29, "id": 8, "name": "rows", "prevSize": 32, "code": 59749 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 95 + }, + { + "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" + ], + "attrs": [{}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["apps"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}], + "properties": { "order": 30, "id": 7, "name": "apps", "prevSize": 32, "code": 59750 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 96 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["address-book"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 31, "id": 6, "name": "address-book", "prevSize": 32, "code": 59751 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 97 + }, + { + "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 + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["chat"], + "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + }, + "attrs": [ + { + "fill": "none", + "strokeLinejoin": "round", + "strokeLinecap": "round", + "strokeMiterlimit": "4", + "strokeWidth": 85.33333333333333 + } + ], + "properties": { "order": 145, "id": 5, "name": "chat", "prevSize": 32, "code": 59752 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 98 + }, + { + "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" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["settings"], + "colorPermutations": { "11611631671181918125521401255951141": [{}] } + }, + "attrs": [{}], + "properties": { "order": 33, "id": 4, "name": "settings", "prevSize": 32, "code": 59753 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 99 + }, + { + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["transactions"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 34, "id": 3, "name": "transactions", "prevSize": 32, "code": 59754 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 100 + }, + { + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["nft"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 35, "id": 2, "name": "nft", "prevSize": 32, "code": 59755 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 101 + }, + { + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["token"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 36, "id": 1, "name": "token", "prevSize": 32, "code": 59756 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 102 + }, + { + "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" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["home"], + "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 37, "id": 0, "name": "home", "prevSize": 32, "code": 59757 }, + "setIdx": 0, + "setId": 2, + "iconIdx": 103 + } + ], + "height": 1024, + "metadata": { "name": "safe-icons" }, + "preferences": { + "showGlyphs": true, + "showQuickUse": true, + "showQuickUse2": true, + "showSVGs": true, + "fontPref": { + "prefix": "icon-", + "metadata": { "fontFamily": "safe-icons", "majorVersion": 1, "minorVersion": 0 }, + "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" + }, + "historySize": 50, + "showCodes": true, + "gridSize": 16 + } +} diff --git a/apps/mobile/resources/icons/safe-icons/style.css b/apps/mobile/resources/icons/safe-icons/style.css new file mode 100644 index 000000000..1719198c8 --- /dev/null +++ b/apps/mobile/resources/icons/safe-icons/style.css @@ -0,0 +1,373 @@ +@font-face { + font-family: 'safe-icons'; + src: + url('fonts/safe-icons.ttf?oqz473') format('truetype'), + url('fonts/safe-icons.woff?oqz473') format('woff'), + url('fonts/safe-icons.svg?oqz473#safe-icons') format('svg'); + font-weight: normal; + font-style: normal; + font-display: block; +} + +[class^="icon-"], [class*=" icon-"] { + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: 'safe-icons' !important; + speak: never; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-block:before { + content: "\e900"; + color: #ff5f72; +} +.icon-alert-triangle:before { + content: "\e901"; + color: #ff5f72; +} +.icon-alert:before { + content: "\e902"; + color: #ff5f72; +} +.icon-info:before { + content: "\e903"; +} +.icon-question:before { + content: "\e904"; +} +.icon-points:before { + content: "\e905"; +} +.icon-code-blocks:before { + content: "\e906"; +} +.icon-hardware:before { + content: "\e907"; +} +.icon-keystone:before { + content: "\e908"; +} +.icon-ledger:before { + content: "\e909"; +} +.icon-seed:before { + content: "\e90a"; +} +.icon-key:before { + content: "\e90b"; +} +.icon-dapp-logo:before { + content: "\e90c"; +} +.icon-double-arrow:before { + content: "\e90d"; +} +.icon-arrow-sort:before { + content: "\e90e"; +} +.icon-dropdown-arrow-small:before { + content: "\e90f"; +} +.icon-options-vertical:before { + content: "\e910"; +} +.icon-options-horizontal:before { + content: "\e911"; +} +.icon-check-oulined:before { + content: "\e912"; +} +.icon-check:before { + content: "\e913"; +} +.icon-check-filled:before { + content: "\e914"; +} +.icon-arrow-down-1:before { + content: "\e915"; +} +.icon-arrow-down:before { + content: "\e916"; +} +.icon-arrow-up:before { + content: "\e917"; +} +.icon-arrow-left:before { + content: "\e918"; +} +.icon-arrow-right:before { + content: "\e919"; +} +.icon-tag:before { + content: "\e91a"; +} +.icon-camera:before { + content: "\e91b"; +} +.icon-element-drag:before { + content: "\e91c"; +} +.icon-transaction-partial-fill:before { + content: "\e91d"; +} +.icon-rows-2 .path1:before { + content: "\e91e"; + color: rgb(0, 0, 0); +} +.icon-rows-2 .path2:before { + content: "\e91f"; + margin-left: -1em; + color: rgb(0, 0, 0); +} +.icon-rows-2 .path3:before { + content: "\e920"; + margin-left: -1em; + color: rgb(0, 0, 0); +} +.icon-rows-2 .path4:before { + content: "\e921"; + margin-left: -1em; + color: rgb(18, 19, 18); +} +.icon-rows-2 .path5:before { + content: "\e922"; + margin-left: -1em; + color: rgb(18, 19, 18); +} +.icon-rows-2 .path6:before { + content: "\e923"; + margin-left: -1em; + color: rgb(18, 19, 18); +} +.icon-check-notifications:before { + content: "\e924"; +} +.icon-qr-code-1:before { + content: "\e925"; +} +.icon-scan-1:before { + content: "\e926"; +} +.icon-shield-crossed:before { + content: "\e927"; +} +.icon-shield:before { + content: "\e928"; +} +.icon-clock:before { + content: "\e929"; +} +.icon-update:before { + content: "\e92a"; +} +.icon-repeat:before { + content: "\e92b"; +} +.icon-download:before { + content: "\e92c"; +} +.icon-upload:before { + content: "\e92d"; +} +.icon-qr-code:before { + content: "\e92e"; +} +.icon-scan:before { + content: "\e92f"; +} +.icon-eye-n:before { + content: "\e930"; +} +.icon-eye-off:before { + content: "\e931"; +} +.icon-unlock:before { + content: "\e932"; +} +.icon-lock:before { + content: "\e933"; +} +.icon-replace-owner:before { + content: "\e934"; +} +.icon-edit-owner:before { + content: "\e935"; +} +.icon-add-owner:before { + content: "\e936"; +} +.icon-send-to:before { + content: "\e937"; +} +.icon-owners:before { + content: "\e938"; +} +.icon-link:before { + content: "\e939"; +} +.icon-share:before { + content: "\e93a"; +} +.icon-external-link:before { + content: "\e93b"; +} +.icon-export:before { + content: "\e93c"; +} +.icon-paste:before { + content: "\e93d"; +} +.icon-copy:before { + content: "\e93e"; +} +.icon-sign:before { + content: "\e93f"; +} +.icon-document:before { + content: "\e940"; +} +.icon-file:before { + content: "\e941"; +} +.icon-search:before { + content: "\e942"; +} +.icon-edit:before { + content: "\e943"; +} +.icon-delete:before { + content: "\e944"; +} +.icon-close-outlined:before { + content: "\e945"; +} +.icon-close-filled:before { + content: "\e946"; +} +.icon-close:before { + content: "\e947"; +} +.icon-plus-outlined:before { + content: "\e948"; +} +.icon-plus-filled:before { + content: "\e949"; +} +.icon-plus:before { + content: "\e94a"; +} +.icon-transaction-batch .path1:before { + content: "\e94b"; + color: rgb(255, 214, 0); +} +.icon-transaction-batch .path2:before { + content: "\e94c"; + margin-left: -1em; + color: rgb(0, 0, 0); +} +.icon-blocks-1:before { + content: "\e94d"; +} +.icon-rows-1:before { + content: "\e94e"; +} +.icon-batch:before { + content: "\e94f"; +} +.icon-filter:before { + content: "\e950"; +} +.icon-bookmark-filled:before { + content: "\e951"; +} +.icon-bookmark:before { + content: "\e952"; +} +.icon-transaction-recovery:before { + content: "\e953"; +} +.icon-transaction-change-settings:before { + content: "\e954"; +} +.icon-transaction-contract:before { + content: "\e955"; +} +.icon-transaction-execute:before { + content: "\e956"; +} +.icon-transaction-stake:before { + content: "\e957"; +} +.icon-transaction-swap:before { + content: "\e958"; +} +.icon-transaction-outgoing:before { + content: "\e959"; +} +.icon-transaction-incoming:before { + content: "\e95a"; +} +.icon-mobile:before { + content: "\e95b"; +} +.icon-wallet:before { + content: "\e95c"; +} +.icon-appearance:before { + content: "\e95d"; +} +.icon-experimental:before { + content: "\e95e"; +} +.icon-desktop:before { + content: "\e95f"; +} +.icon-safe:before { + content: "\e960"; +} +.icon-bell:before { + content: "\e961"; +} +.icon-lightbulb:before { + content: "\e962"; +} +.icon-what-is-new:before { + content: "\e963"; +} +.icon-blocks:before { + content: "\e964"; +} +.icon-rows:before { + content: "\e965"; +} +.icon-apps:before { + content: "\e966"; +} +.icon-address-book:before { + content: "\e967"; +} +.icon-chat:before { + content: "\e968"; +} +.icon-settings:before { + content: "\e969"; +} +.icon-transactions:before { + content: "\e96a"; +} +.icon-nft:before { + content: "\e96b"; +} +.icon-token:before { + content: "\e96c"; +} +.icon-home:before { + content: "\e96d"; +} diff --git a/apps/mobile/resources/icons/source-svgs/add-owner.svg b/apps/mobile/resources/icons/source-svgs/add-owner.svg new file mode 100644 index 000000000..e2de36b50 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/add-owner.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/address-book.svg b/apps/mobile/resources/icons/source-svgs/address-book.svg new file mode 100644 index 000000000..5b39535f0 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/address-book.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/alert-triangle.svg b/apps/mobile/resources/icons/source-svgs/alert-triangle.svg new file mode 100644 index 000000000..dbd021d61 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/alert-triangle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/alert.svg b/apps/mobile/resources/icons/source-svgs/alert.svg new file mode 100644 index 000000000..c69118d18 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/appearance.svg b/apps/mobile/resources/icons/source-svgs/appearance.svg new file mode 100644 index 000000000..1971adba9 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/appearance.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/apps.svg b/apps/mobile/resources/icons/source-svgs/apps.svg new file mode 100644 index 000000000..6a03223dc --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/apps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/arrow-down-1.svg b/apps/mobile/resources/icons/source-svgs/arrow-down-1.svg new file mode 100644 index 000000000..fe10e9838 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/arrow-down-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/arrow-down.svg b/apps/mobile/resources/icons/source-svgs/arrow-down.svg new file mode 100644 index 000000000..1ec931b14 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/arrow-left.svg b/apps/mobile/resources/icons/source-svgs/arrow-left.svg new file mode 100644 index 000000000..3260c611f --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/arrow-right.svg b/apps/mobile/resources/icons/source-svgs/arrow-right.svg new file mode 100644 index 000000000..e813007d1 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/arrow-sort.svg b/apps/mobile/resources/icons/source-svgs/arrow-sort.svg new file mode 100644 index 000000000..3c5bb96c7 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/arrow-sort.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/arrow-up.svg b/apps/mobile/resources/icons/source-svgs/arrow-up.svg new file mode 100644 index 000000000..0f06e3a2b --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/batch.svg b/apps/mobile/resources/icons/source-svgs/batch.svg new file mode 100644 index 000000000..ec1994936 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/batch.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/bell.svg b/apps/mobile/resources/icons/source-svgs/bell.svg new file mode 100644 index 000000000..4386eff7c --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/bell.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/block.svg b/apps/mobile/resources/icons/source-svgs/block.svg new file mode 100644 index 000000000..f2bd56de5 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/block.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/blocks-1.svg b/apps/mobile/resources/icons/source-svgs/blocks-1.svg new file mode 100644 index 000000000..b6a5f2b3f --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/blocks-1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/blocks.svg b/apps/mobile/resources/icons/source-svgs/blocks.svg new file mode 100644 index 000000000..b6a5f2b3f --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/blocks.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/bookmark-filled.svg b/apps/mobile/resources/icons/source-svgs/bookmark-filled.svg new file mode 100644 index 000000000..be2282353 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/bookmark-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/bookmark.svg b/apps/mobile/resources/icons/source-svgs/bookmark.svg new file mode 100644 index 000000000..235fbb225 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/bookmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/camera.svg b/apps/mobile/resources/icons/source-svgs/camera.svg new file mode 100644 index 000000000..790aaa911 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/camera.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/chat.svg b/apps/mobile/resources/icons/source-svgs/chat.svg new file mode 100644 index 000000000..342d6eb8b --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/check-filled.svg b/apps/mobile/resources/icons/source-svgs/check-filled.svg new file mode 100644 index 000000000..caf30f2cf --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/check-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/check-notifications.svg b/apps/mobile/resources/icons/source-svgs/check-notifications.svg new file mode 100644 index 000000000..60d44b0a3 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/check-notifications.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/check-oulined.svg b/apps/mobile/resources/icons/source-svgs/check-oulined.svg new file mode 100644 index 000000000..0e9037cef --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/check-oulined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/check.svg b/apps/mobile/resources/icons/source-svgs/check.svg new file mode 100644 index 000000000..cf4c2ba17 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/clock.svg b/apps/mobile/resources/icons/source-svgs/clock.svg new file mode 100644 index 000000000..15ec73691 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/close-filled.svg b/apps/mobile/resources/icons/source-svgs/close-filled.svg new file mode 100644 index 000000000..9fa57c4b5 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/close-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/close-outlined.svg b/apps/mobile/resources/icons/source-svgs/close-outlined.svg new file mode 100644 index 000000000..e722ae973 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/close-outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/close.svg b/apps/mobile/resources/icons/source-svgs/close.svg new file mode 100644 index 000000000..b6caeb3b9 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/code-blocks.svg b/apps/mobile/resources/icons/source-svgs/code-blocks.svg new file mode 100644 index 000000000..d6d3558a3 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/code-blocks.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/copy.svg b/apps/mobile/resources/icons/source-svgs/copy.svg new file mode 100644 index 000000000..2faacebe4 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/dapp-logo.svg b/apps/mobile/resources/icons/source-svgs/dapp-logo.svg new file mode 100644 index 000000000..741d5ecee --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/dapp-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/delete.svg b/apps/mobile/resources/icons/source-svgs/delete.svg new file mode 100644 index 000000000..75a3d50cc --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/delete.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/desktop.svg b/apps/mobile/resources/icons/source-svgs/desktop.svg new file mode 100644 index 000000000..6c0f6907a --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/desktop.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/document.svg b/apps/mobile/resources/icons/source-svgs/document.svg new file mode 100644 index 000000000..ad3e1cfa9 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/document.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/double-arrow.svg b/apps/mobile/resources/icons/source-svgs/double-arrow.svg new file mode 100644 index 000000000..0b82f202a --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/double-arrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/download.svg b/apps/mobile/resources/icons/source-svgs/download.svg new file mode 100644 index 000000000..08cb308af --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/download.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/dropdown-arrow-small.svg b/apps/mobile/resources/icons/source-svgs/dropdown-arrow-small.svg new file mode 100644 index 000000000..f42ace4a9 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/dropdown-arrow-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/edit-owner.svg b/apps/mobile/resources/icons/source-svgs/edit-owner.svg new file mode 100644 index 000000000..2047f6fb8 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/edit-owner.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/edit.svg b/apps/mobile/resources/icons/source-svgs/edit.svg new file mode 100644 index 000000000..80e917039 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/element-drag.svg b/apps/mobile/resources/icons/source-svgs/element-drag.svg new file mode 100644 index 000000000..a496771ee --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/element-drag.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/experimental.svg b/apps/mobile/resources/icons/source-svgs/experimental.svg new file mode 100644 index 000000000..bae035cd4 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/experimental.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/export.svg b/apps/mobile/resources/icons/source-svgs/export.svg new file mode 100644 index 000000000..33c2b3de8 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/export.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/external-link.svg b/apps/mobile/resources/icons/source-svgs/external-link.svg new file mode 100644 index 000000000..9fdb171a2 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/external-link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/eye-n.svg b/apps/mobile/resources/icons/source-svgs/eye-n.svg new file mode 100644 index 000000000..dafa314db --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/eye-n.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/eye-off.svg b/apps/mobile/resources/icons/source-svgs/eye-off.svg new file mode 100644 index 000000000..90ab431f6 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/eye-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/file.svg b/apps/mobile/resources/icons/source-svgs/file.svg new file mode 100644 index 000000000..082704540 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/file.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/filter.svg b/apps/mobile/resources/icons/source-svgs/filter.svg new file mode 100644 index 000000000..23e1396cc --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/hardware.svg b/apps/mobile/resources/icons/source-svgs/hardware.svg new file mode 100644 index 000000000..0c5729ab3 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/hardware.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/home.svg b/apps/mobile/resources/icons/source-svgs/home.svg new file mode 100644 index 000000000..be5e4118c --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/info.svg b/apps/mobile/resources/icons/source-svgs/info.svg new file mode 100644 index 000000000..45d002761 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/key.svg b/apps/mobile/resources/icons/source-svgs/key.svg new file mode 100644 index 000000000..a937862c3 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/key.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/keystone.svg b/apps/mobile/resources/icons/source-svgs/keystone.svg new file mode 100644 index 000000000..efd796e73 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/keystone.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/ledger.svg b/apps/mobile/resources/icons/source-svgs/ledger.svg new file mode 100644 index 000000000..4567f0a2e --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/ledger.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/lightbulb.svg b/apps/mobile/resources/icons/source-svgs/lightbulb.svg new file mode 100644 index 000000000..4d07899dc --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/lightbulb.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/link.svg b/apps/mobile/resources/icons/source-svgs/link.svg new file mode 100644 index 000000000..6afdc9044 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/lock.svg b/apps/mobile/resources/icons/source-svgs/lock.svg new file mode 100644 index 000000000..405030522 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/mobile.svg b/apps/mobile/resources/icons/source-svgs/mobile.svg new file mode 100644 index 000000000..ef826b94c --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/mobile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/nft.svg b/apps/mobile/resources/icons/source-svgs/nft.svg new file mode 100644 index 000000000..52c27e82a --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/nft.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/options-horizontal.svg b/apps/mobile/resources/icons/source-svgs/options-horizontal.svg new file mode 100644 index 000000000..82596ecce --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/options-horizontal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/options-vertical.svg b/apps/mobile/resources/icons/source-svgs/options-vertical.svg new file mode 100644 index 000000000..59b05f6f2 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/options-vertical.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/owners.svg b/apps/mobile/resources/icons/source-svgs/owners.svg new file mode 100644 index 000000000..84230b06a --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/owners.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/paste.svg b/apps/mobile/resources/icons/source-svgs/paste.svg new file mode 100644 index 000000000..4a56163fa --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/paste.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/plus-filled.svg b/apps/mobile/resources/icons/source-svgs/plus-filled.svg new file mode 100644 index 000000000..6fa6e614a --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/plus-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/plus-outlined.svg b/apps/mobile/resources/icons/source-svgs/plus-outlined.svg new file mode 100644 index 000000000..bd36825b9 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/plus-outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/plus.svg b/apps/mobile/resources/icons/source-svgs/plus.svg new file mode 100644 index 000000000..f60361c29 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/points.svg b/apps/mobile/resources/icons/source-svgs/points.svg new file mode 100644 index 000000000..f91e82a4d --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/points.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/qr-code-1.svg b/apps/mobile/resources/icons/source-svgs/qr-code-1.svg new file mode 100644 index 000000000..66ff2c9f4 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/qr-code-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/qr-code.svg b/apps/mobile/resources/icons/source-svgs/qr-code.svg new file mode 100644 index 000000000..66ff2c9f4 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/qr-code.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/question.svg b/apps/mobile/resources/icons/source-svgs/question.svg new file mode 100644 index 000000000..eafb900b4 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/question.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/repeat.svg b/apps/mobile/resources/icons/source-svgs/repeat.svg new file mode 100644 index 000000000..f77b79739 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/repeat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/replace-owner.svg b/apps/mobile/resources/icons/source-svgs/replace-owner.svg new file mode 100644 index 000000000..7644982f0 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/replace-owner.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/rows-1.svg b/apps/mobile/resources/icons/source-svgs/rows-1.svg new file mode 100644 index 000000000..73bfee78f --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/rows-1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/rows-2.svg b/apps/mobile/resources/icons/source-svgs/rows-2.svg new file mode 100644 index 000000000..e2fcbeb2e --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/rows-2.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/rows.svg b/apps/mobile/resources/icons/source-svgs/rows.svg new file mode 100644 index 000000000..73bfee78f --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/rows.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/safe.svg b/apps/mobile/resources/icons/source-svgs/safe.svg new file mode 100644 index 000000000..4ee47b4e4 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/safe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/scan-1.svg b/apps/mobile/resources/icons/source-svgs/scan-1.svg new file mode 100644 index 000000000..d0be3efd0 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/scan-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/scan.svg b/apps/mobile/resources/icons/source-svgs/scan.svg new file mode 100644 index 000000000..d0be3efd0 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/scan.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/search.svg b/apps/mobile/resources/icons/source-svgs/search.svg new file mode 100644 index 000000000..9265f48be --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/seed.svg b/apps/mobile/resources/icons/source-svgs/seed.svg new file mode 100644 index 000000000..9c26ef276 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/seed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/send-to.svg b/apps/mobile/resources/icons/source-svgs/send-to.svg new file mode 100644 index 000000000..1c21308cd --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/send-to.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/settings.svg b/apps/mobile/resources/icons/source-svgs/settings.svg new file mode 100644 index 000000000..f5ef77e43 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/share.svg b/apps/mobile/resources/icons/source-svgs/share.svg new file mode 100644 index 000000000..ef2a82cba --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/shield-crossed.svg b/apps/mobile/resources/icons/source-svgs/shield-crossed.svg new file mode 100644 index 000000000..cc308d84b --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/shield-crossed.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/shield.svg b/apps/mobile/resources/icons/source-svgs/shield.svg new file mode 100644 index 000000000..e2efccad3 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/sign.svg b/apps/mobile/resources/icons/source-svgs/sign.svg new file mode 100644 index 000000000..2f73fcdb3 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/sign.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/tag.svg b/apps/mobile/resources/icons/source-svgs/tag.svg new file mode 100644 index 000000000..8fe0a6d4e --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/tag.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/token.svg b/apps/mobile/resources/icons/source-svgs/token.svg new file mode 100644 index 000000000..fde8bb546 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/token.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-batch.svg b/apps/mobile/resources/icons/source-svgs/transaction-batch.svg new file mode 100644 index 000000000..5662be33a --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-batch.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-change-settings.svg b/apps/mobile/resources/icons/source-svgs/transaction-change-settings.svg new file mode 100644 index 000000000..fb7e59525 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-change-settings.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-contract.svg b/apps/mobile/resources/icons/source-svgs/transaction-contract.svg new file mode 100644 index 000000000..837de855b --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-contract.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-execute.svg b/apps/mobile/resources/icons/source-svgs/transaction-execute.svg new file mode 100644 index 000000000..b5af65351 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-execute.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-incoming.svg b/apps/mobile/resources/icons/source-svgs/transaction-incoming.svg new file mode 100644 index 000000000..b4beb2247 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-incoming.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-outgoing.svg b/apps/mobile/resources/icons/source-svgs/transaction-outgoing.svg new file mode 100644 index 000000000..783cbf087 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-outgoing.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-partial-fill.svg b/apps/mobile/resources/icons/source-svgs/transaction-partial-fill.svg new file mode 100644 index 000000000..7d8ac41a8 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-partial-fill.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-recovery.svg b/apps/mobile/resources/icons/source-svgs/transaction-recovery.svg new file mode 100644 index 000000000..40a7ec9e8 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-recovery.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-stake.svg b/apps/mobile/resources/icons/source-svgs/transaction-stake.svg new file mode 100644 index 000000000..eaa40d329 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-stake.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/transaction-swap.svg b/apps/mobile/resources/icons/source-svgs/transaction-swap.svg new file mode 100644 index 000000000..d5cc049db --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transaction-swap.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/transactions.svg b/apps/mobile/resources/icons/source-svgs/transactions.svg new file mode 100644 index 000000000..11e0b508f --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/transactions.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/unlock.svg b/apps/mobile/resources/icons/source-svgs/unlock.svg new file mode 100644 index 000000000..08cb95728 --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/unlock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/update.svg b/apps/mobile/resources/icons/source-svgs/update.svg new file mode 100644 index 000000000..3dac3e76f --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/update.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/upload.svg b/apps/mobile/resources/icons/source-svgs/upload.svg new file mode 100644 index 000000000..5a63431ff --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/resources/icons/source-svgs/wallet.svg b/apps/mobile/resources/icons/source-svgs/wallet.svg new file mode 100644 index 000000000..786d4ce5f --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/wallet.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/resources/icons/source-svgs/what-is-new.svg b/apps/mobile/resources/icons/source-svgs/what-is-new.svg new file mode 100644 index 000000000..0de615a6e --- /dev/null +++ b/apps/mobile/resources/icons/source-svgs/what-is-new.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/scripts/generateIconTypes.js b/apps/mobile/scripts/generateIconTypes.js new file mode 100644 index 000000000..d36bbaccc --- /dev/null +++ b/apps/mobile/scripts/generateIconTypes.js @@ -0,0 +1,26 @@ +/* eslint-disable */ +/** + * This script generates the possible names for the SafeFontIcon component + */ + +const fs = require('fs') +const path = require('path') + +const selectionFilePath = path.join(__dirname, '../assets/fonts/safe-icons/selection.json') + +// Read the selection.json file +const selection = JSON.parse(fs.readFileSync(selectionFilePath, 'utf8')) + +// Get the icon names +const iconNames = selection.icons.map((icon) => icon.icon.tags[0]).filter(Boolean) + +// Create TypeScript union type +const typeDef = `export type IconName =\n ${iconNames.map((name) => `| '${name}'`).join('\n ')}\n` + +// Create an array of icon names +const arrayDef = `export const iconNames: IconName[] = [\n ${iconNames.map((name) => `'${name}'`).join(',\n ')},\n]` + +// Write the type definition to a file +fs.writeFileSync(path.join(__dirname, '../src/types/iconTypes.ts'), `${typeDef}\n${arrayDef}\n`) + +console.log('Icon type and Icon names generated') diff --git a/apps/mobile/scripts/reset-project.js b/apps/mobile/scripts/reset-project.js new file mode 100755 index 000000000..9b4f780b6 --- /dev/null +++ b/apps/mobile/scripts/reset-project.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +/** + * This script is used to reset the project to a blank state. + * It moves the /app directory to /app-example and creates a new /app directory with an SafeFontIcon.tsx and _layout.tsx file. + * You can remove the `reset-project` script from package.json and safely delete this file after running it. + */ + +const fs = require('fs') +const path = require('path') + +const root = process.cwd() +const oldDirPath = path.join(root, 'app') +const newDirPath = path.join(root, 'app-example') +const newAppDirPath = path.join(root, 'app') + +const indexContent = `import { Text, View } from "react-native"; + +export default function Index() { + return ( + + Edit app/index.tsx to edit this screen. + + ); +} +` + +const layoutContent = `import { Stack } from "expo-router"; + +export default function RootLayout() { + return ( + + + + ); +} +` + +fs.rename(oldDirPath, newDirPath, (error) => { + if (error) { + return console.error(`Error renaming directory: ${error}`) + } + console.log('/app moved to /app-example.') + + fs.mkdir(newAppDirPath, { recursive: true }, (error) => { + if (error) { + return console.error(`Error creating new app directory: ${error}`) + } + console.log('New /app directory created.') + + const indexPath = path.join(newAppDirPath, 'SafeFontIcon.tsx') + fs.writeFile(indexPath, indexContent, (error) => { + if (error) { + return console.error(`Error creating index.tsx: ${error}`) + } + console.log('app/SafeFontIcon.tsx created.') + + const layoutPath = path.join(newAppDirPath, '_layout.tsx') + fs.writeFile(layoutPath, layoutContent, (error) => { + if (error) { + return console.error(`Error creating _layout.tsx: ${error}`) + } + console.log('app/_layout.tsx created.') + }) + }) + }) +}) diff --git a/apps/mobile/src/components/Alert/Alert.stories.tsx b/apps/mobile/src/components/Alert/Alert.stories.tsx new file mode 100644 index 000000000..1a7a5ad51 --- /dev/null +++ b/apps/mobile/src/components/Alert/Alert.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Alert } from '@/src/components/Alert' + +const meta: Meta = { + title: 'Alert', + component: Alert, + argTypes: { + type: { control: 'select', options: ['error', 'warning', 'info'] }, + message: { type: 'string' }, + iconName: { type: 'string' }, + displayIcon: { type: 'boolean' }, + }, +} + +export default meta + +type Story = StoryObj + +export const Warning: Story = { + args: { + type: 'warning', + message: 'Proceed with caution', + displayIcon: true, + }, +} + +export const Error: Story = { + args: { + type: 'error', + message: 'The transaction will most likely fail', + displayIcon: true, + }, +} + +export const Info: Story = { + args: { + type: 'info', + message: 'This is info block', + displayIcon: true, + }, +} diff --git a/apps/mobile/src/components/Alert/Alert.test.tsx b/apps/mobile/src/components/Alert/Alert.test.tsx new file mode 100644 index 000000000..b8b261ed9 --- /dev/null +++ b/apps/mobile/src/components/Alert/Alert.test.tsx @@ -0,0 +1,96 @@ +import { render, userEvent } from '@/src/tests/test-utils' +import { Alert } from '.' +import { SafeFontIcon } from '../SafeFontIcon/SafeFontIcon' + +describe('Alert', () => { + it('should render a info alert', async () => { + const container = render() + + expect(container.getByText('Info alert')).toBeTruthy() + expect(container.getByTestId('info-icon')).toBeTruthy() + expect(container.queryByTestId('alert-end-icon')).not.toBeTruthy() + expect(container.queryByTestId('alert-start-icon')).not.toBeTruthy() + }) + it('should render a info alert without icon', () => { + const container = render() + + expect(container.getByText('Info alert')).toBeTruthy() + expect(container.queryByTestId('info-icon')).not.toBeTruthy() + expect(container.queryByTestId('alert-end-icon')).not.toBeTruthy() + expect(container.queryByTestId('alert-start-icon')).not.toBeTruthy() + }) + + it('should render a warning alert', () => { + const container = render() + + expect(container.getByText('Warning alert')).toBeTruthy() + expect(container.queryByTestId('warning-icon')).toBeTruthy() + expect(container.queryByTestId('alert-end-icon')).not.toBeTruthy() + expect(container.queryByTestId('alert-start-icon')).not.toBeTruthy() + }) + + it('should render a warning alert without icon', () => { + const container = render() + + expect(container.getByText('Warning alert')).toBeTruthy() + expect(container.queryByTestId('warning-icon')).not.toBeTruthy() + expect(container.queryByTestId('alert-end-icon')).not.toBeTruthy() + expect(container.queryByTestId('alert-start-icon')).not.toBeTruthy() + }) + + it('should render an error alert', () => { + const container = render() + + expect(container.getByText('Error alert')).toBeTruthy() + expect(container.queryByTestId('error-icon')).toBeTruthy() + expect(container.queryByTestId('alert-end-icon')).not.toBeTruthy() + expect(container.queryByTestId('alert-start-icon')).not.toBeTruthy() + }) + + it('should render an error alert without icon', () => { + const container = render() + + expect(container.getByText('Error alert')).toBeTruthy() + expect(container.queryByTestId('error-icon')).not.toBeTruthy() + expect(container.queryByTestId('alert-end-icon')).not.toBeTruthy() + expect(container.queryByTestId('alert-start-icon')).not.toBeTruthy() + }) + + it('should be able to click in the alert component if an onPress function is passed', async () => { + const user = userEvent.setup() + const mockFn = jest.fn() + const container = render( + , + ) + + await user.press(container.getByText('Click to see something')) + + expect(mockFn).toHaveBeenCalled() + }) + + it('should render an alert with start icon', () => { + const container = render( + } + message="Error alert" + />, + ) + + expect(container.queryByTestId('add-owner-icon')).toBeTruthy() + }) + + it('should render an alert with an end icon', () => { + const container = render( + } message="Error alert" />, + ) + + expect(container.queryByTestId('add-owner-icon')).toBeTruthy() + }) + + it('should render an alert with a name icon', () => { + const container = render() + + expect(container.queryByTestId('add-owner-icon')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/Alert/Alert.tsx b/apps/mobile/src/components/Alert/Alert.tsx new file mode 100644 index 000000000..88a2e5ce5 --- /dev/null +++ b/apps/mobile/src/components/Alert/Alert.tsx @@ -0,0 +1,85 @@ +import { View, Text, Theme } from 'tamagui' +import React, { type ReactElement } from 'react' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { IconName } from '@/src/types/iconTypes' +import { TouchableOpacity } from 'react-native' + +export type AlertType = 'error' | 'warning' | 'info' | 'success' + +interface AlertProps { + type: AlertType + message: string + info?: string + iconName?: IconName + displayIcon?: boolean + fullWidth?: boolean + endIcon?: React.ReactNode + startIcon?: React.ReactNode + onPress?: () => void + testID?: string +} + +const icons = { + error: , + warning: , + info: , + success: , +} + +const getAlertIcon = (type: AlertType, iconName?: IconName, displayIcon?: boolean): ReactElement | null => { + if (!displayIcon) { + return null + } + + return iconName ? : icons[type] +} + +export const Alert = ({ + type, + fullWidth = true, + message, + iconName, + startIcon, + endIcon, + displayIcon = true, + onPress, + testID, + info, +}: AlertProps) => { + const Icon = getAlertIcon(type, iconName, displayIcon) + return ( + + + + + {startIcon ? {startIcon} : Icon} + + + + {message} + + + {info && ( + + {info} + + )} + + + {endIcon && {endIcon}} + + + + + ) +} diff --git a/apps/mobile/src/components/Alert/index.ts b/apps/mobile/src/components/Alert/index.ts new file mode 100644 index 000000000..67c990447 --- /dev/null +++ b/apps/mobile/src/components/Alert/index.ts @@ -0,0 +1,3 @@ +import { Alert } from './Alert' + +export { Alert } diff --git a/apps/mobile/src/components/Badge/Badge.tsx b/apps/mobile/src/components/Badge/Badge.tsx new file mode 100644 index 000000000..b42d9612b --- /dev/null +++ b/apps/mobile/src/components/Badge/Badge.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { Circle, CircleProps, Text, TextProps, Theme, View } from 'tamagui' +import { badgeTheme } from '@/src/components/Badge/theme' + +type BadgeThemeKeys = keyof typeof badgeTheme + +type ExtractAfterUnderscore = T extends `${string}_${infer Rest}` ? Rest : never +type BadgeThemeTypes = ExtractAfterUnderscore + +interface BadgeProps { + content: string | React.ReactElement + themeName?: BadgeThemeTypes + circleSize?: string + fontSize?: TextProps['fontSize'] + circleProps?: Partial + textContentProps?: Partial + circular?: boolean + testID?: string +} + +export const Badge = ({ + content, + circleSize = '$7', + fontSize = 14, + themeName = 'badge_warning', + circular = true, + circleProps, + textContentProps, + testID, +}: BadgeProps) => { + let contentToRender = content + if (typeof content === 'string') { + contentToRender = ( + + {content} + + ) + } + + if (circular) { + return ( + + + {contentToRender} + + + ) + } + return ( + + + {contentToRender} + + + ) +} diff --git a/apps/mobile/src/components/Badge/badge.stories.tsx b/apps/mobile/src/components/Badge/badge.stories.tsx new file mode 100644 index 000000000..a4d968778 --- /dev/null +++ b/apps/mobile/src/components/Badge/badge.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import React from 'react' +import { Text, View } from 'tamagui' + +const meta: Meta = { + title: 'Badge', + component: Badge, + args: { + content: '3/9', + }, +} + +export default meta + +type Story = StoryObj + +export const Circular: Story = { + args: { + content: '12+', + }, +} +export const CircularWithIcon: Story = { + render: function Render(args) { + return } /> + }, +} +export const NonCircular: Story = { + args: { + content: 'Badge', + circular: false, + }, +} + +export const NonCircularBold: Story = { + args: { + content: 'Badge', + circular: false, + textContentProps: { + fontWeight: 700, + }, + }, +} + +export const NonCircularWithComplexContent: Story = { + args: { + circular: false, + }, + render: function Render(args) { + return ( + + + + + 3/9 + + + } + /> + ) + }, +} diff --git a/apps/mobile/src/components/Badge/index.ts b/apps/mobile/src/components/Badge/index.ts new file mode 100644 index 000000000..7a972c445 --- /dev/null +++ b/apps/mobile/src/components/Badge/index.ts @@ -0,0 +1,3 @@ +import { Badge } from './Badge' + +export { Badge } diff --git a/apps/mobile/src/components/Badge/theme.ts b/apps/mobile/src/components/Badge/theme.ts new file mode 100644 index 000000000..4501e0888 --- /dev/null +++ b/apps/mobile/src/components/Badge/theme.ts @@ -0,0 +1,44 @@ +import { tokens } from '@/src/theme/tokens' + +export const badgeTheme = { + light_badge_success: { + background: tokens.color.successLightDark, + color: tokens.color.backgroundMainDark, + }, + dark_badge_success: { + color: tokens.color.backgroundMainDark, + background: tokens.color.primaryMainDark, + }, + light_badge_success_variant1: { + background: tokens.color.successDarkDark, + color: tokens.color.successMainLight, + }, + dark_badge_success_variant1: { + background: tokens.color.successDarkDark, + color: tokens.color.successMainLight, + }, + light_badge_warning: { + color: tokens.color.warning1MainLight, + background: tokens.color.warningBackgroundLight, + }, + dark_badge_warning: { + color: tokens.color.warning1MainDark, + background: tokens.color.warning1ContrastTextDark, + }, + light_badge_warning_variant1: { + color: tokens.color.warning1ContrastTextLight, + background: tokens.color.warningDarkDark, + }, + dark_badge_warning_variant1: { + color: tokens.color.warning1ContrastTextDark, + background: tokens.color.warningDarkDark, + }, + dark_badge_background: { + color: tokens.color.textPrimaryDark, + background: tokens.color.logoBackgroundDark, + }, + light_badge_background: { + color: tokens.color.textPrimaryLight, + background: tokens.color.logoBackgroundLight, + }, +} diff --git a/apps/mobile/src/components/BlurredIdenticonBackground/BlurredIdenticonBackground.stories.tsx b/apps/mobile/src/components/BlurredIdenticonBackground/BlurredIdenticonBackground.stories.tsx new file mode 100644 index 000000000..ae0cef881 --- /dev/null +++ b/apps/mobile/src/components/BlurredIdenticonBackground/BlurredIdenticonBackground.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { BlurredIdenticonBackground } from '@/src/components/BlurredIdenticonBackground' +import { View } from 'tamagui' + +const meta: Meta = { + title: 'BlurredIdenticonBackground', + component: BlurredIdenticonBackground, + argTypes: {}, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + address: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + }, + decorators: [ + (Story) => ( + // This is a hack to make the story full screen + // we apply global decorator padding of 16 in preview.tsx + // and then we remove it here + + + + ), + ], + parameters: { + layout: 'fullscreen', + }, +} diff --git a/apps/mobile/src/components/BlurredIdenticonBackground/BlurredIdenticonBackground.tsx b/apps/mobile/src/components/BlurredIdenticonBackground/BlurredIdenticonBackground.tsx new file mode 100644 index 000000000..1b6af57cf --- /dev/null +++ b/apps/mobile/src/components/BlurredIdenticonBackground/BlurredIdenticonBackground.tsx @@ -0,0 +1,77 @@ +import { blo } from 'blo' +import { View } from 'tamagui' +import { Image } from 'expo-image' +import { Dimensions, StyleSheet, useColorScheme } from 'react-native' +import { BlurView } from 'expo-blur' +import React from 'react' +import { Address } from '@/src/types/address' + +type Props = { + address: Address + height?: number + children: React.ReactNode +} +export const BlurredIdenticonBackground = ({ address, height = 125, children }: Props) => { + const blockie = blo(address) + const colorScheme = useColorScheme() + + return ( + + + + + + + + + + {children} + + + ) +} + +const styles = StyleSheet.create({ + containerInner: { + position: 'relative', + }, + containerInnerBackground: { + position: 'absolute', + width: '100%', + height: '100%', + }, + // Android cannot handle border-radius on Image component + // so we need to wrap it in a View with borderRadius + androidHack: { + borderRadius: '50%', + overflow: 'hidden', + bottom: 20, + position: 'absolute', + }, + identicon: { + width: Dimensions.get('window').width, + height: Dimensions.get('window').width, + }, + blurView: { + position: 'absolute', + bottom: 0, + width: '100%', + }, +}) diff --git a/apps/mobile/src/components/BlurredIdenticonBackground/index.tsx b/apps/mobile/src/components/BlurredIdenticonBackground/index.tsx new file mode 100644 index 000000000..fcca8f3f8 --- /dev/null +++ b/apps/mobile/src/components/BlurredIdenticonBackground/index.tsx @@ -0,0 +1 @@ +export { BlurredIdenticonBackground } from './BlurredIdenticonBackground' diff --git a/apps/mobile/src/components/ChainsDisplay/ChainsDisplay.stories.tsx b/apps/mobile/src/components/ChainsDisplay/ChainsDisplay.stories.tsx new file mode 100644 index 000000000..2538f318a --- /dev/null +++ b/apps/mobile/src/components/ChainsDisplay/ChainsDisplay.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { ChainsDisplay } from '@/src/components/ChainsDisplay' +import { mockedChains } from '@/src/store/constants' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +const meta: Meta = { + title: 'ChainsDisplay', + component: ChainsDisplay, + argTypes: {}, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + chains: mockedChains as unknown as Chain[], + max: 3, + }, + parameters: { + layout: 'fullscreen', + }, +} + +export const Truncated: Story = { + args: { + chains: mockedChains as unknown as Chain[], + max: 1, + }, + parameters: { + layout: 'fullscreen', + }, +} + +export const ActiveChain: Story = { + args: { + chains: mockedChains as unknown as Chain[], + activeChainId: mockedChains[1].chainId, + max: 1, + }, + parameters: { + layout: 'fullscreen', + }, +} diff --git a/apps/mobile/src/components/ChainsDisplay/ChainsDisplay.test.tsx b/apps/mobile/src/components/ChainsDisplay/ChainsDisplay.test.tsx new file mode 100644 index 000000000..29c093dc0 --- /dev/null +++ b/apps/mobile/src/components/ChainsDisplay/ChainsDisplay.test.tsx @@ -0,0 +1,30 @@ +import { mockedChains } from '@/src/store/constants' +import { ChainsDisplay } from './ChainsDisplay' +import { render } from '@testing-library/react-native' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +describe('ChainsDisplay', () => { + it('should render all chains next each other', () => { + const container = render() + + expect(container.getAllByTestId('chain-display')).toHaveLength(3) + }) + it('should truncate the chains when the provided chains length is greatter than the max', () => { + const container = render() + const moreChainsBadge = container.getByTestId('more-chains-badge') + + expect(container.getAllByTestId('chain-display')).toHaveLength(2) + expect(moreChainsBadge).toBeVisible() + expect(moreChainsBadge).toHaveTextContent('+1') + }) + + it('should always show the selected chain as the first column of the row', () => { + const container = render( + , + ) + + expect(container.getAllByTestId('chain-display')[0].children[0].props.accessibilityLabel).toBe( + mockedChains[2].chainName, + ) + }) +}) diff --git a/apps/mobile/src/components/ChainsDisplay/ChainsDisplay.tsx b/apps/mobile/src/components/ChainsDisplay/ChainsDisplay.tsx new file mode 100644 index 000000000..d7a2731ec --- /dev/null +++ b/apps/mobile/src/components/ChainsDisplay/ChainsDisplay.tsx @@ -0,0 +1,34 @@ +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import React, { useMemo } from 'react' +import { View } from 'tamagui' +import { Logo } from '../Logo' +import { Badge } from '../Badge' + +interface ChainsDisplayProps { + chains: Chain[] + max?: number + activeChainId?: string +} + +export function ChainsDisplay({ chains, activeChainId, max }: ChainsDisplayProps) { + const orderedChains = useMemo( + () => [...chains].sort((a, b) => (a.chainId === activeChainId ? -1 : b.chainId === activeChainId ? 1 : 0)), + [chains], + ) + const slicedChains = max ? orderedChains.slice(0, max) : chains + const showBadge = max && chains.length > max + + return ( + + {slicedChains.map(({ chainLogoUri, chainName, chainId }, index) => ( + + + + ))} + + {showBadge && ( + + )} + + ) +} diff --git a/apps/mobile/src/components/ChainsDisplay/index.ts b/apps/mobile/src/components/ChainsDisplay/index.ts new file mode 100644 index 000000000..5c0ef338d --- /dev/null +++ b/apps/mobile/src/components/ChainsDisplay/index.ts @@ -0,0 +1 @@ +export { ChainsDisplay } from './ChainsDisplay' diff --git a/apps/mobile/src/components/Container/Container.stories.tsx b/apps/mobile/src/components/Container/Container.stories.tsx new file mode 100644 index 000000000..b3d617691 --- /dev/null +++ b/apps/mobile/src/components/Container/Container.stories.tsx @@ -0,0 +1,23 @@ +import type { StoryObj, Meta } from '@storybook/react' +import { Container } from '@/src/components/Container' +import { Text } from 'tamagui' + +const meta: Meta = { + title: 'Container', + component: Container, + args: { + children: 'Some text', + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: (args) => ( + + Some text + + ), +} diff --git a/apps/mobile/src/components/Container/Container.test.tsx b/apps/mobile/src/components/Container/Container.test.tsx new file mode 100644 index 000000000..79d043b81 --- /dev/null +++ b/apps/mobile/src/components/Container/Container.test.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { render } from '@testing-library/react-native' +import { Container } from './index' +import { Text } from 'react-native' + +describe('Container', () => { + it('renders correctly with children', () => { + const { getByText } = render( + + Test Child + , + ) + expect(getByText('Test Child')).toBeTruthy() + }) + + it('applies the correct styles', () => { + const { getByTestId } = render( + + Test Child + , + ) + const container = getByTestId('container') + expect(container.props.style).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/components/Container/Container.tsx b/apps/mobile/src/components/Container/Container.tsx new file mode 100644 index 000000000..c92e72ea9 --- /dev/null +++ b/apps/mobile/src/components/Container/Container.tsx @@ -0,0 +1,40 @@ +import { styled, Theme, ThemeName, YStack, YStackProps } from 'tamagui' + +const StyledYStack = styled(YStack, { + variants: { + bordered: { + true: { + borderColor: '#303033', + borderWidth: 1, + }, + false: { + backgroundColor: '$backgroundPaper', + }, + }, + transparent: { + true: { + backgroundColor: 'transparent', + borderWidth: 0, + }, + }, + } as const, +}) + +export const Container = ( + props: YStackProps & { bordered?: boolean; spaced?: boolean; transparent?: boolean; themeName?: ThemeName }, +) => { + const { children, bordered, themeName = 'container', spaced = true, ...rest } = props + return ( + + + {children} + + + ) +} diff --git a/apps/mobile/src/components/Container/__snapshots__/Container.test.tsx.snap b/apps/mobile/src/components/Container/__snapshots__/Container.test.tsx.snap new file mode 100644 index 000000000..d7ea5a52a --- /dev/null +++ b/apps/mobile/src/components/Container/__snapshots__/Container.test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Container applies the correct styles 1`] = ` +{ + "borderBottomLeftRadius": 7, + "borderBottomRightRadius": 7, + "borderTopLeftRadius": 7, + "borderTopRightRadius": 7, + "flexDirection": "column", + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, +} +`; diff --git a/apps/mobile/src/components/Container/index.ts b/apps/mobile/src/components/Container/index.ts new file mode 100644 index 000000000..0b2730f6f --- /dev/null +++ b/apps/mobile/src/components/Container/index.ts @@ -0,0 +1,3 @@ +import { Container } from './Container' + +export { Container } diff --git a/apps/mobile/src/components/CopyButton/CopyButton.stories.tsx b/apps/mobile/src/components/CopyButton/CopyButton.stories.tsx new file mode 100644 index 000000000..bd027f510 --- /dev/null +++ b/apps/mobile/src/components/CopyButton/CopyButton.stories.tsx @@ -0,0 +1,21 @@ +import type { StoryObj, Meta } from '@storybook/react' +import { CopyButton } from '@/src/components/CopyButton/index' + +const meta: Meta = { + title: 'CopyButton', + component: CopyButton, + args: {}, +} + +export default meta + +type Story = StoryObj + +/** + * Displays a copy button. On press, the value passed is copied to the clipboard. + */ +export const Default: Story = { + args: { + value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + }, +} diff --git a/apps/mobile/src/components/CopyButton/CopyButton.tsx b/apps/mobile/src/components/CopyButton/CopyButton.tsx new file mode 100644 index 000000000..8bbb3ed3b --- /dev/null +++ b/apps/mobile/src/components/CopyButton/CopyButton.tsx @@ -0,0 +1,18 @@ +import { Button, TextProps } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' + +export const CopyButton = ({ value, color }: { value: string; color: TextProps['color'] }) => { + const copyAndDispatchToast = useCopyAndDispatchToast() + return ( + + ) +} diff --git a/apps/mobile/src/components/CopyButton/index.ts b/apps/mobile/src/components/CopyButton/index.ts new file mode 100644 index 000000000..7c281caef --- /dev/null +++ b/apps/mobile/src/components/CopyButton/index.ts @@ -0,0 +1,3 @@ +import { CopyButton } from './CopyButton' + +export { CopyButton } diff --git a/apps/mobile/src/components/DataRow/DataRow.stories.tsx b/apps/mobile/src/components/DataRow/DataRow.stories.tsx new file mode 100644 index 000000000..cc9479533 --- /dev/null +++ b/apps/mobile/src/components/DataRow/DataRow.stories.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { Container } from '@/src/components/Container' +import { DataRow } from '@/src/components/DataRow' +import { XStack, Text } from 'tamagui' + +export default { + title: 'DataRow', + component: DataRow, + decorators: [ + (Story: React.ComponentType) => ( + + + + ), + ], +} + +// Basic usage of DataRow with Label and Value +export const Default = () => ( + + Send + 0.05452 ETH + +) + +// DataRow with a Header and values below it +export const WithHeader = () => ( + <> + Transaction Details + + Recipient + 0x13d91...4589 + + + Network + Ethereum + + +) + +// DataRow showcasing more complex ReactNode as Value +export const ComplexValue = () => ( + + Recipient + + + 0x13d91...4589 + + (Verified) + + + + +) diff --git a/apps/mobile/src/components/DataRow/DataRow.test.tsx b/apps/mobile/src/components/DataRow/DataRow.test.tsx new file mode 100644 index 000000000..49609e716 --- /dev/null +++ b/apps/mobile/src/components/DataRow/DataRow.test.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { render } from '@testing-library/react-native' +import { DataRow } from './index' +import { Text } from 'react-native' +import { View } from 'tamagui' + +describe('DataRow', () => { + it('renders correctly with children', () => { + const { getByText } = render( + + Test Child + , + ) + expect(getByText('Test Child')).toBeTruthy() + }) + + it('applies the correct styles', () => { + const { getByTestId } = render( + + Test Child + , + ) + const dataRow = getByTestId('data-row') + expect(dataRow.props.style).toMatchObject({ + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8, + }) + }) +}) + +describe('DataRow.Label', () => { + it('renders correctly with children', () => { + const { getByText } = render(Label) + expect(getByText('Label')).toBeTruthy() + }) + + it('applies the correct styles', () => { + const { getByText } = render(Label) + const label = getByText('Label') + expect(label.props.style).toMatchObject({ + fontWeight: 'bold', + }) + }) +}) + +describe('DataRow.Value', () => { + it('renders correctly with children', () => { + const { getByText } = render(Value) + expect(getByText('Value')).toBeTruthy() + }) + + it('renders correctly with children as a React node', () => { + const { getByText } = render( + + + bob + + , + ) + expect(getByText('bob')).toBeTruthy() + }) +}) + +describe('DataRow.Header', () => { + it('renders correctly with children', () => { + const { getByText } = render(Header Child) + expect(getByText('Header Child')).toBeTruthy() + }) + + it('applies the correct styles', () => { + const { getByText } = render(Header Child) + const header = getByText('Header Child') + expect(header.props.style).toMatchObject({ + fontWeight: '600', + }) + }) +}) diff --git a/apps/mobile/src/components/DataRow/DataRow.tsx b/apps/mobile/src/components/DataRow/DataRow.tsx new file mode 100644 index 000000000..a460e3b9b --- /dev/null +++ b/apps/mobile/src/components/DataRow/DataRow.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { XStack, Text, Theme, XStackProps } from 'tamagui' + +type Props = { + children: string +} + +type ValueProps = { + children: string | React.ReactElement +} + +export const DataRow: React.FC & { + Label: React.FC + Value: React.FC + Header: React.FC +} = (props: XStackProps) => { + const { children, ...rest } = props + return ( + + {children} + + ) +} + +const Label = ({ children }: Props) => ( + + {children} + +) + +const Value = ({ children }: { children: string | React.ReactElement }) => { + if (typeof children === 'string') { + return {children} + } + + return children +} + +const Header = ({ children }: Props) => ( + + {children} + +) + +DataRow.Label = Label +DataRow.Value = Value +DataRow.Header = Header diff --git a/apps/mobile/src/components/DataRow/index.ts b/apps/mobile/src/components/DataRow/index.ts new file mode 100644 index 000000000..52f82bcee --- /dev/null +++ b/apps/mobile/src/components/DataRow/index.ts @@ -0,0 +1,2 @@ +import { DataRow } from './DataRow' +export { DataRow } diff --git a/apps/mobile/src/components/Dropdown/Dropdown.test.tsx b/apps/mobile/src/components/Dropdown/Dropdown.test.tsx new file mode 100644 index 000000000..c8b8d0b71 --- /dev/null +++ b/apps/mobile/src/components/Dropdown/Dropdown.test.tsx @@ -0,0 +1,52 @@ +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 new file mode 100644 index 000000000..9ef4ec31a --- /dev/null +++ b/apps/mobile/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,158 @@ +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/index.ts b/apps/mobile/src/components/Dropdown/index.ts new file mode 100644 index 000000000..80f50ef94 --- /dev/null +++ b/apps/mobile/src/components/Dropdown/index.ts @@ -0,0 +1,2 @@ +import { Dropdown } from './Dropdown' +export { Dropdown } diff --git a/apps/mobile/src/components/Dropdown/sheetComponents.tsx b/apps/mobile/src/components/Dropdown/sheetComponents.tsx new file mode 100644 index 000000000..8710158f3 --- /dev/null +++ b/apps/mobile/src/components/Dropdown/sheetComponents.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { View as RCView, StyleSheet } from 'react-native' +import { View } from 'tamagui' +import { BottomSheetBackgroundProps, useBottomSheetModal } from '@gorhom/bottom-sheet' +import { BlurView } from 'expo-blur' + +const BackgroundComponent = React.memo(({ style }: BottomSheetBackgroundProps) => { + return ( + + + + ) +}) + +const BackdropComponent = React.memo(() => { + const { dismiss } = useBottomSheetModal() + + const handleClose = () => dismiss() + + return ( + + + + ) +}) + +BackgroundComponent.displayName = 'BackgroundComponent' +BackdropComponent.displayName = 'BackdropComponent' + +const styles = StyleSheet.create({ + absolute: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + width: '100%', + height: '100%', + }, +}) + +export { BackgroundComponent, BackdropComponent } diff --git a/apps/mobile/src/components/EthAddress/ETHAddress.stories.tsx b/apps/mobile/src/components/EthAddress/ETHAddress.stories.tsx new file mode 100644 index 000000000..006cd3675 --- /dev/null +++ b/apps/mobile/src/components/EthAddress/ETHAddress.stories.tsx @@ -0,0 +1,25 @@ +import type { StoryObj, Meta } from '@storybook/react' +import { EthAddress } from '@/src/components/EthAddress' + +const meta: Meta = { + title: 'EthAddress', + component: EthAddress, + args: {}, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + address: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + }, +} + +export const WithCopy: Story = { + args: { + address: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + copy: true, + }, +} diff --git a/apps/mobile/src/components/EthAddress/ETHAddress.tsx b/apps/mobile/src/components/EthAddress/ETHAddress.tsx new file mode 100644 index 000000000..08cecb995 --- /dev/null +++ b/apps/mobile/src/components/EthAddress/ETHAddress.tsx @@ -0,0 +1,20 @@ +import { Address } from '@/src/types/address' +import { shortenAddress } from '@/src/utils/formatters' +import { Text, type TextProps, View } from 'tamagui' +import { CopyButton } from '@/src/components/CopyButton' + +type Props = { + address: Address + copy?: boolean + textProps?: Partial +} +export const EthAddress = ({ address, copy, textProps }: Props) => { + return ( + + + {shortenAddress(address)} + + {copy && } + + ) +} diff --git a/apps/mobile/src/components/EthAddress/index.ts b/apps/mobile/src/components/EthAddress/index.ts new file mode 100644 index 000000000..1e81222b1 --- /dev/null +++ b/apps/mobile/src/components/EthAddress/index.ts @@ -0,0 +1,2 @@ +import { EthAddress } from './ETHAddress' +export { EthAddress } diff --git a/apps/mobile/src/components/Fiat/Fiat.test.tsx b/apps/mobile/src/components/Fiat/Fiat.test.tsx new file mode 100644 index 000000000..f6c105a93 --- /dev/null +++ b/apps/mobile/src/components/Fiat/Fiat.test.tsx @@ -0,0 +1,12 @@ +import { render } from '@/src/tests/test-utils' +import { Fiat } from '.' + +describe('Fiat', () => { + it('should render the default markup', () => { + const { getByText } = render() + + expect(getByText('$')).toBeTruthy() + expect(getByText('215,531')).toBeTruthy() + expect(getByText('.65')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/Fiat/Fiat.tsx b/apps/mobile/src/components/Fiat/Fiat.tsx new file mode 100644 index 000000000..ff233da34 --- /dev/null +++ b/apps/mobile/src/components/Fiat/Fiat.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { H1, H3, View } from 'tamagui' + +interface FiatProps { + baseAmount: string +} + +export const Fiat = ({ baseAmount }: FiatProps) => { + const amount = baseAmount.split('.') + + return ( + +

$

+

{amount[0]}

+ + {amount[1] && ( +

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

+ )} +
+ ) +} diff --git a/apps/mobile/src/components/Fiat/index.ts b/apps/mobile/src/components/Fiat/index.ts new file mode 100644 index 000000000..f6acb3036 --- /dev/null +++ b/apps/mobile/src/components/Fiat/index.ts @@ -0,0 +1,2 @@ +import { Fiat } from './Fiat' +export { Fiat } diff --git a/apps/mobile/src/components/FloatingContainer/FloatingContainer.tsx b/apps/mobile/src/components/FloatingContainer/FloatingContainer.tsx new file mode 100644 index 000000000..40c4481c7 --- /dev/null +++ b/apps/mobile/src/components/FloatingContainer/FloatingContainer.tsx @@ -0,0 +1,61 @@ +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' + +interface FloatingContainerProps { + children: React.ReactNode + noOffset?: boolean + sticky?: boolean + keyboardAvoidEnabled?: boolean + onLayout?: KeyboardAvoidingViewProps['onLayout'] + testID?: string +} + +export const FloatingContainer: FC = ({ + children, + noOffset, + sticky, + keyboardAvoidEnabled, + onLayout, + testID, +}: FloatingContainerProps) => { + const bottomInset = useSafeAreaInsets().bottom + const deviceBottom = Layout.isSmallDevice ? 10 : 20 + + const bottomPadding = useMemo(() => { + return Math.max(bottomInset, deviceBottom) + }, [bottomInset]) + + const keyboardVerticalOffset = useMemo(() => { + return noOffset ? 0 : Platform.select({ ios: 40, default: 0 }) + }, [noOffset]) + + return ( + + {children} + + ) +} + +const styles = StyleSheet.create({ + floatingContainer: { + position: 'fixed', + bottom: -40, + width: '100%', + zIndex: 1, + }, + childContainer: { + flexDirection: 'column', + justifyContent: 'space-between', + flexGrow: 1, + }, +}) diff --git a/apps/mobile/src/components/FloatingContainer/index.ts b/apps/mobile/src/components/FloatingContainer/index.ts new file mode 100644 index 000000000..1b5d10931 --- /dev/null +++ b/apps/mobile/src/components/FloatingContainer/index.ts @@ -0,0 +1,2 @@ +import { FloatingContainer } from './FloatingContainer' +export { FloatingContainer } diff --git a/apps/mobile/src/components/Identicon/Identicon.stories.tsx b/apps/mobile/src/components/Identicon/Identicon.stories.tsx new file mode 100644 index 000000000..4c308a3f9 --- /dev/null +++ b/apps/mobile/src/components/Identicon/Identicon.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Identicon } from '@/src/components/Identicon' +import { type Address } from '@/src/types/address' + +const defaultProps = { + address: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6' as Address, + size: 56, +} +const meta: Meta = { + title: 'Identicon', + component: Identicon, + args: defaultProps, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Rounded: Story = { + args: { + ...defaultProps, + rounded: true, + }, +} diff --git a/apps/mobile/src/components/Identicon/Identicon.test.tsx b/apps/mobile/src/components/Identicon/Identicon.test.tsx new file mode 100644 index 000000000..73aa12e91 --- /dev/null +++ b/apps/mobile/src/components/Identicon/Identicon.test.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { render } from '@testing-library/react-native' +import { Identicon } from './index' + +describe('Identicon', () => { + it('renders correctly with address', () => { + const { getByTestId } = render() + const image = getByTestId('identicon-image') + expect(image).toBeTruthy() + }) + + it('applies rounded style when rounded prop is true', () => { + const { getByTestId } = render() + const image = getByTestId('identicon-image') + expect(image.props.style.borderRadius).toBe('50%') + }) + + 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) + }) + + 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) + }) +}) diff --git a/apps/mobile/src/components/Identicon/Identicon.tsx b/apps/mobile/src/components/Identicon/Identicon.tsx new file mode 100644 index 000000000..1d2cdd249 --- /dev/null +++ b/apps/mobile/src/components/Identicon/Identicon.tsx @@ -0,0 +1,27 @@ +import { blo } from 'blo' +import { Image } from 'expo-image' +import { type Address } from '@/src/types/address' +import { View } from 'tamagui' + +type Props = { + address: Address + rounded?: boolean + size?: number +} + +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, + } + + const blockie = blo(address) + + return ( + + + + ) +} diff --git a/apps/mobile/src/components/Identicon/index.ts b/apps/mobile/src/components/Identicon/index.ts new file mode 100644 index 000000000..b086ee405 --- /dev/null +++ b/apps/mobile/src/components/Identicon/index.ts @@ -0,0 +1,2 @@ +import { Identicon } from './Identicon' +export { Identicon } diff --git a/apps/mobile/src/components/InnerShadow/InnerShadow.test.tsx b/apps/mobile/src/components/InnerShadow/InnerShadow.test.tsx new file mode 100644 index 000000000..44a79f3fd --- /dev/null +++ b/apps/mobile/src/components/InnerShadow/InnerShadow.test.tsx @@ -0,0 +1,10 @@ +import { render } from '@/src/tests/test-utils' +import { InnerShadow } from '.' + +describe('InnerShadow', () => { + it('should render the default markup', () => { + const container = render() + + expect(container).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/components/InnerShadow/InnerShadow.tsx b/apps/mobile/src/components/InnerShadow/InnerShadow.tsx new file mode 100644 index 000000000..4079d2663 --- /dev/null +++ b/apps/mobile/src/components/InnerShadow/InnerShadow.tsx @@ -0,0 +1,14 @@ +import { styled, View } from 'tamagui' + +export const InnerShadow = styled(View, { + position: 'absolute', + bottom: 0, + height: 10, + left: 0, + width: '100%', + backgroundColor: '$background', + shadowColor: '$background', + shadowOffset: { width: -2, height: -4 }, + shadowRadius: 4, + shadowOpacity: 1, +}) diff --git a/apps/mobile/src/components/InnerShadow/__snapshots__/InnerShadow.test.tsx.snap b/apps/mobile/src/components/InnerShadow/__snapshots__/InnerShadow.test.tsx.snap new file mode 100644 index 000000000..bef7d551e --- /dev/null +++ b/apps/mobile/src/components/InnerShadow/__snapshots__/InnerShadow.test.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InnerShadow should render the default markup 1`] = ` + + + +`; diff --git a/apps/mobile/src/components/InnerShadow/index.ts b/apps/mobile/src/components/InnerShadow/index.ts new file mode 100644 index 000000000..975b859a6 --- /dev/null +++ b/apps/mobile/src/components/InnerShadow/index.ts @@ -0,0 +1,2 @@ +import { InnerShadow } from './InnerShadow' +export { InnerShadow } diff --git a/apps/mobile/src/components/Logo/Logo.test.tsx b/apps/mobile/src/components/Logo/Logo.test.tsx new file mode 100644 index 000000000..685288f03 --- /dev/null +++ b/apps/mobile/src/components/Logo/Logo.test.tsx @@ -0,0 +1,17 @@ +import { render } from '@/src/tests/test-utils' +import { Logo } from '.' + +describe('Logo', () => { + it('should render the default markup', () => { + const container = render() + + expect(container.getByLabelText('Mocked logo')).toBeTruthy() + }) + + it('should render the fallback markup', () => { + const container = render() + + expect(container.queryByTestId('logo-image')).not.toBeTruthy() + 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 new file mode 100644 index 000000000..15bc1a427 --- /dev/null +++ b/apps/mobile/src/components/Logo/Logo.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Avatar, Theme, View } from 'tamagui' +import { IconProps, SafeFontIcon } from '../SafeFontIcon/SafeFontIcon' + +interface LogoProps { + logoUri?: string | null + accessibilityLabel?: string + fallbackIcon?: IconProps['name'] + imageBackground?: string + size?: string +} + +export function Logo({ + logoUri, + accessibilityLabel, + size = '$10', + imageBackground = '$color', + fallbackIcon = 'nft', +}: LogoProps) { + return ( + + + {logoUri && ( + + )} + + + + + + + + + ) +} diff --git a/apps/mobile/src/components/Logo/index.ts b/apps/mobile/src/components/Logo/index.ts new file mode 100644 index 000000000..5e5089137 --- /dev/null +++ b/apps/mobile/src/components/Logo/index.ts @@ -0,0 +1,2 @@ +import { Logo } from './Logo' +export { Logo } diff --git a/apps/mobile/src/components/OptIn/OptIn.tsx b/apps/mobile/src/components/OptIn/OptIn.tsx new file mode 100644 index 000000000..3c98f6483 --- /dev/null +++ b/apps/mobile/src/components/OptIn/OptIn.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { ImageSourcePropType, StyleSheet } from 'react-native' +import { View, Image, Text } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import { WINDOW_HEIGHT } from '@/src/store/constants' +import { FloatingContainer } from '../FloatingContainer' + +interface OptInProps { + title: string + ctaButton: { + onPress: () => void + label: string + } + kicker?: string + description?: string + image?: ImageSourcePropType + secondaryButton?: { + onPress: () => void + label: string + } + testID?: string + isVisible?: boolean +} + +export const OptIn: React.FC = React.memo( + ({ testID, kicker, title, description, image, ctaButton, secondaryButton, isVisible }: OptInProps) => { + if (!isVisible) { + return + } + + return ( + + {kicker && ( + + {kicker} + + )} + + {title} + + {description && ( + + {description} + + )} + {image && } + + + + {secondaryButton && ( + + )} + + + ) + }, +) + +const styles = StyleSheet.create({ + wrapper: { + flex: 1, + }, + image: { + width: '100%', + height: Math.abs(WINDOW_HEIGHT * 0.42), + }, +}) + +OptIn.displayName = 'OptIn' diff --git a/apps/mobile/src/components/OptIn/index.ts b/apps/mobile/src/components/OptIn/index.ts new file mode 100644 index 000000000..3515066da --- /dev/null +++ b/apps/mobile/src/components/OptIn/index.ts @@ -0,0 +1,2 @@ +import { OptIn } from './OptIn' +export { OptIn } diff --git a/apps/mobile/src/components/SafeButton/SafeButton.stories.tsx b/apps/mobile/src/components/SafeButton/SafeButton.stories.tsx new file mode 100644 index 000000000..fc79031e4 --- /dev/null +++ b/apps/mobile/src/components/SafeButton/SafeButton.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { SafeButton } from '@/src/components/SafeButton' +import { action } from '@storybook/addon-actions' + +const meta: Meta = { + title: 'SafeButton', + component: SafeButton, + args: { + label: 'Get started', + onPress: action('onPress'), + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} diff --git a/apps/mobile/src/components/SafeButton/SafeButton.tsx b/apps/mobile/src/components/SafeButton/SafeButton.tsx new file mode 100644 index 000000000..56820a923 --- /dev/null +++ b/apps/mobile/src/components/SafeButton/SafeButton.tsx @@ -0,0 +1,32 @@ +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, +}) + +export function SafeButton({ onPress, label, variant = 'primary' }: SafeButtonProps) { + const variantStyles = + variant === 'primary' + ? { backgroundColor: '$primary', fontColor: '$background' } + : { backgroundColor: 'inherit', fontColor: '$primary' } + return ( + + + + {label} + + + + ) +} diff --git a/apps/mobile/src/components/SafeButton/index.ts b/apps/mobile/src/components/SafeButton/index.ts new file mode 100644 index 000000000..4e666e430 --- /dev/null +++ b/apps/mobile/src/components/SafeButton/index.ts @@ -0,0 +1,2 @@ +import { SafeButton } from './SafeButton' +export { SafeButton } diff --git a/apps/mobile/src/components/SafeCard/SafeCard.stories.tsx b/apps/mobile/src/components/SafeCard/SafeCard.stories.tsx new file mode 100644 index 000000000..c02b58c9f --- /dev/null +++ b/apps/mobile/src/components/SafeCard/SafeCard.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { SafeCard } from '@/src/components/SafeCard' +import { SafeFontIcon } from '../SafeFontIcon' +import Seed from '@/assets/images/seed.png' +import { Text } from 'tamagui' + +const meta: Meta = { + title: 'SafeCard', + component: SafeCard, + args: { + title: 'Welcome to Safe', + description: 'Add a new owner to your Safe', + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + title: 'Welcome to Safe', + description: 'Add a new owner to your Safe', + icon: , + image: Seed, + }, +} + +export const OnlyText: Story = { + args: { + title: 'Welcome to Safe', + description: 'Add a new owner to your Safe', + }, +} + +export const withChildren: Story = { + args: { + title: 'Welcome to Safe', + description: 'Add a new owner to your Safe', + children: Hello from children, + }, +} diff --git a/apps/mobile/src/components/SafeCard/SafeCard.test.tsx b/apps/mobile/src/components/SafeCard/SafeCard.test.tsx new file mode 100644 index 000000000..813e62b54 --- /dev/null +++ b/apps/mobile/src/components/SafeCard/SafeCard.test.tsx @@ -0,0 +1,56 @@ +import { render } from '@/src/tests/test-utils' +import { SafeCard } from './SafeCard' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Text } from 'tamagui' + +describe('SafeCard', () => { + const defaultProps = { + title: 'Test Title', + description: 'Test Description', + } + + it('renders basic card with title and description', () => { + const { getByText } = render() + + expect(getByText('Test Title')).toBeTruthy() + expect(getByText('Test Description')).toBeTruthy() + }) + + it('renders with icon', () => { + const { getByTestId } = render( + } />, + ) + + expect(getByTestId('test-icon')).toBeTruthy() + }) + + it('renders with image', () => { + const testImage = { uri: 'test-image.png' } + const { getByTestId } = render() + + expect(getByTestId('safe-card-image')).toBeTruthy() + }) + + it('renders with children', () => { + const { getByText } = render( + + Child Content + , + ) + + expect(getByText('Child Content')).toBeTruthy() + }) + + it('renders all optional elements together', () => { + const testImage = { uri: 'test-image.png' } + const { getByTestId, getByText } = render( + } image={testImage}> + Child Content + , + ) + + expect(getByTestId('test-icon')).toBeTruthy() + expect(getByTestId('safe-card-image')).toBeTruthy() + expect(getByText('Child Content')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/SafeCard/SafeCard.tsx b/apps/mobile/src/components/SafeCard/SafeCard.tsx new file mode 100644 index 000000000..6a3e2a50c --- /dev/null +++ b/apps/mobile/src/components/SafeCard/SafeCard.tsx @@ -0,0 +1,46 @@ +import { H5, Image, Text, View } from 'tamagui' +import { Badge } from '../Badge' +import { Container } from '../Container' +import { ImageSourcePropType } from 'react-native' +import { ReactElement } from 'react' + +interface SafeCardProps { + title: string + description: string + image?: ImageSourcePropType + icon?: ReactElement + children?: React.ReactNode +} + +export function SafeCard({ title, description, image, icon, children }: SafeCardProps) { + return ( + + {icon && } + +
+ {title} +
+ + + {description} + + + {children} + + {image && ( + + + + )} +
+ ) +} diff --git a/apps/mobile/src/components/SafeCard/index.ts b/apps/mobile/src/components/SafeCard/index.ts new file mode 100644 index 000000000..63feb16b5 --- /dev/null +++ b/apps/mobile/src/components/SafeCard/index.ts @@ -0,0 +1 @@ +export { SafeCard } from './SafeCard' diff --git a/apps/mobile/src/components/SafeFontIcon/SafeFontIcon.stories.tsx b/apps/mobile/src/components/SafeFontIcon/SafeFontIcon.stories.tsx new file mode 100644 index 000000000..67c82d53a --- /dev/null +++ b/apps/mobile/src/components/SafeFontIcon/SafeFontIcon.stories.tsx @@ -0,0 +1,35 @@ +import { View, Text, ScrollView } from 'tamagui' +import type { Meta, StoryObj } from '@storybook/react' +import { SafeFontIcon } from './SafeFontIcon' +import { iconNames } from '@/src/types/iconTypes' + +const meta: Meta = { + component: SafeFontIcon, + argTypes: { + color: { control: 'color' }, + }, +} + +export default meta + +type Story = StoryObj + +export const AllIcons: Story = { + render: (args) => { + return ( + + + {iconNames.map((iconName) => ( + + + {iconName} + + ))} + + + ) + }, + args: { + size: 50, + }, +} diff --git a/apps/mobile/src/components/SafeFontIcon/SafeFontIcon.tsx b/apps/mobile/src/components/SafeFontIcon/SafeFontIcon.tsx new file mode 100644 index 000000000..903be123c --- /dev/null +++ b/apps/mobile/src/components/SafeFontIcon/SafeFontIcon.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import createIconSetFromIcoMoon from '@expo/vector-icons/createIconSetFromIcoMoon' +import { useFonts } from 'expo-font' +import { IconName } from '@/src/types/iconTypes' +import { getVariable, useTheme } from 'tamagui' + +export const SafeIcon = createIconSetFromIcoMoon( + require('@/assets/fonts/safe-icons/selection.json'), + 'SafeIcons', + 'safe-icons.ttf', +) + +export interface IconProps { + name: IconName + size?: number + color?: string + testID?: string +} + +export const SafeFontIcon = ({ name, size = 24, color, ...rest }: IconProps) => { + const theme = useTheme() + const iconColor = color ? theme[color]?.get() || getVariable(color, 'color') : theme.color?.get() + const [fontsLoaded] = useFonts({ + SafeIcons: require('@/assets/fonts/safe-icons/safe-icons.ttf'), + }) + + if (!fontsLoaded) { + return null + } + + return +} diff --git a/apps/mobile/src/components/SafeFontIcon/index.ts b/apps/mobile/src/components/SafeFontIcon/index.ts new file mode 100644 index 000000000..b3472d49e --- /dev/null +++ b/apps/mobile/src/components/SafeFontIcon/index.ts @@ -0,0 +1,2 @@ +import { SafeFontIcon } from './SafeFontIcon' +export { SafeFontIcon } diff --git a/apps/mobile/src/components/SafeInput/SafeInput.stories.tsx b/apps/mobile/src/components/SafeInput/SafeInput.stories.tsx new file mode 100644 index 000000000..fc315654c --- /dev/null +++ b/apps/mobile/src/components/SafeInput/SafeInput.stories.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { SafeInput, SafeInputProps } from './SafeInput' +import type { Meta, StoryObj } from '@storybook/react' +import { View } from 'tamagui' + +const SafeInputMeta: Meta = { + title: 'Components/SafeInput', + component: SafeInput, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + height: 52, + }, + argTypes: { + placeholder: { + control: 'text', + description: 'Placeholder text', + }, + value: { + control: 'text', + description: 'Input value', + }, + error: { + control: 'text', + description: 'Error message', + }, + multiline: { + control: 'boolean', + description: 'Enable multiline input', + }, + textAlign: { + control: { + type: 'select', + options: ['left', 'center', 'right'], + }, + description: 'Text alignment', + }, + }, +} + +export default SafeInputMeta + +type SafeInputStory = StoryObj + +export const Default: SafeInputStory = (args: SafeInputProps) => +Default.args = { + placeholder: 'Enter text...', + value: '', +} + +export const WithError: SafeInputStory = (args: SafeInputProps) => +WithError.args = { + placeholder: 'Enter text...', + value: 'Invalid input', + error: 'This is an error message', +} + +export const Multiline: SafeInputStory = (args: SafeInputProps) => +Multiline.args = { + placeholder: 'Enter multiple lines...', + value: '', + multiline: true, + height: 100, +} + +export const CenteredText: SafeInputStory = (args: SafeInputProps) => +CenteredText.args = { + placeholder: 'Centered text...', + value: 'This text is centered', + textAlign: 'center', +} + +export const WithLongText: SafeInputStory = (args: SafeInputProps) => +WithLongText.args = { + placeholder: 'Enter text...', + value: 'This is a very long text that should wrap to multiple lines when it exceeds the width of the input component', + multiline: true, + height: 100, +} diff --git a/apps/mobile/src/components/SafeInput/SafeInput.test.tsx b/apps/mobile/src/components/SafeInput/SafeInput.test.tsx new file mode 100644 index 000000000..a4a94ad64 --- /dev/null +++ b/apps/mobile/src/components/SafeInput/SafeInput.test.tsx @@ -0,0 +1,50 @@ +import { render } from '@/src/tests/test-utils' +import { SafeInput } from './SafeInput' +import { Text } from 'tamagui' + +describe('SafeInput', () => { + it('should render the default component', () => { + const { getByTestId } = 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') + }) + + 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(getByText('This field is required')).toBeDefined() + }) + + it('should accept a custom error message component', () => { + 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(getByText('This field is required')).toBeDefined() + }) + + it('should change the color when a 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') + }) +}) diff --git a/apps/mobile/src/components/SafeInput/SafeInput.tsx b/apps/mobile/src/components/SafeInput/SafeInput.tsx new file mode 100644 index 000000000..21081b04f --- /dev/null +++ b/apps/mobile/src/components/SafeInput/SafeInput.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { InputProps, Text, Theme, View } from 'tamagui' +import { StyledInput, StyledInputContainer } from './styled' +import { getInputThemeName } from './utils' +import { SafeFontIcon } from '../SafeFontIcon' + +export interface SafeInputProps { + error?: React.ReactNode | string + placeholder?: string + height?: number + success?: boolean + left?: React.ReactNode + right?: React.ReactNode +} + +const ErrorDisplay = ({ error }: { error: React.ReactNode | string }) => { + if (typeof error === 'string') { + return ( + + + + {error} + + + ) + } + return error +} + +export function SafeInput({ + error, + success, + placeholder, + height = 52, + left, + right, + ...props +}: SafeInputProps & Omit) { + const hasError = !!error + + return ( + + + {left} + + + {right} + + {hasError && } + + ) +} diff --git a/apps/mobile/src/components/SafeInput/styled.ts b/apps/mobile/src/components/SafeInput/styled.ts new file mode 100644 index 000000000..159289b5d --- /dev/null +++ b/apps/mobile/src/components/SafeInput/styled.ts @@ -0,0 +1,27 @@ +import { Input, styled, View } from 'tamagui' + +export const StyledInputContainer = styled(View, { + borderWidth: 2, + borderRadius: '$4', + borderColor: '$borderColor', + flex: 1, + flexDirection: 'row', + paddingHorizontal: '$3', + alignItems: 'center', + justifyContent: 'center', + marginBottom: '$3', + + variants: { + error: { + true: { + borderWidth: 2, + }, + }, + }, +}) + +export const StyledInput = styled(Input, { + color: '$inputTextColor', + placeholderTextColor: '$placeholderColor', + borderWidth: 0, +}) diff --git a/apps/mobile/src/components/SafeInput/theme.ts b/apps/mobile/src/components/SafeInput/theme.ts new file mode 100644 index 000000000..93d206d32 --- /dev/null +++ b/apps/mobile/src/components/SafeInput/theme.ts @@ -0,0 +1,40 @@ +import { tokens } from '@/src/theme/tokens' + +export const inputTheme = { + light_input_default: { + borderColor: tokens.color.borderLightLight, + textColor: tokens.color.textPrimaryLight, + placeholderColor: tokens.color.textSecondaryLight, + inputTextColor: tokens.color.textPrimaryLight, + }, + dark_input_default: { + borderColor: tokens.color.borderLightDark, + textColor: tokens.color.textPrimaryDark, + placeholderColor: tokens.color.textSecondaryDark, + inputTextColor: tokens.color.textPrimaryDark, + }, + light_input_success: { + borderColor: tokens.color.primaryMainDark, + textColor: tokens.color.textPrimaryLight, + placeholderColor: tokens.color.textSecondaryLight, + inputTextColor: tokens.color.textPrimaryLight, + }, + dark_input_success: { + borderColor: tokens.color.primaryMainDark, + textColor: tokens.color.textPrimaryDark, + placeholderColor: tokens.color.textSecondaryDark, + inputTextColor: tokens.color.textPrimaryDark, + }, + light_input_error: { + borderColor: tokens.color.errorMainLight, + textColor: tokens.color.errorMainLight, + placeholderColor: tokens.color.errorMainLight, + inputTextColor: tokens.color.textPrimaryLight, + }, + dark_input_error: { + borderColor: tokens.color.errorMainDark, + textColor: tokens.color.errorMainDark, + placeholderColor: tokens.color.errorMainDark, + inputTextColor: tokens.color.textPrimaryDark, + }, +} diff --git a/apps/mobile/src/components/SafeInput/utils.ts b/apps/mobile/src/components/SafeInput/utils.ts new file mode 100644 index 000000000..544488e3d --- /dev/null +++ b/apps/mobile/src/components/SafeInput/utils.ts @@ -0,0 +1,11 @@ +export const getInputThemeName = (hasError?: boolean, hasSuccess?: boolean) => { + if (hasError) { + return 'error' + } + + if (hasSuccess) { + return 'success' + } + + return 'default' +} diff --git a/apps/mobile/src/components/SafeListItem/SafeListItem.test.tsx b/apps/mobile/src/components/SafeListItem/SafeListItem.test.tsx new file mode 100644 index 000000000..53599f7c9 --- /dev/null +++ b/apps/mobile/src/components/SafeListItem/SafeListItem.test.tsx @@ -0,0 +1,76 @@ +import { render } from '@/src/tests/test-utils' +import { SafeListItem } from '.' +import { Text, View } from 'tamagui' +import { ellipsis } from '@/src/utils/formatters' + +describe('SafeListItem', () => { + it('should render the default markup', () => { + const { getByText } = render( + Left node} rightNode={Right node} />, + ) + + expect(getByText('A label')).toBeTruthy() + expect(getByText('Left node')).toBeTruthy() + expect(getByText('Right node')).toBeTruthy() + }) + + it('should render a list item, with type and icon', () => { + const { getByText, getByTestId } = render( + Left node} + rightNode={Right node} + />, + ) + + expect(getByText('A label')).toBeTruthy() + expect(getByText('some type')).toBeTruthy() + expect(getByTestId('safe-list-add-owner-icon')).toBeTruthy() + expect(getByText('Left node')).toBeTruthy() + expect(getByText('Right node')).toBeTruthy() + }) + + it('should render a list item with truncated label when the label text length is very long', () => { + const text = 'A very long label text to test if it it will truncate, in this case it should truncate.' + const { getByText, getByTestId } = render( + Left node} />, + ) + + expect(getByText(ellipsis(text, 30))).toBeTruthy() + expect(getByText('some type')).toBeTruthy() + expect(getByTestId('safe-list-add-owner-icon')).toBeTruthy() + expect(getByText('Left node')).toBeTruthy() + }) + + it('should render a list item with a custom label template', () => { + const container = render( + + Here is my label +
+ } + type="some type" + icon="add-owner" + leftNode={Left node} + />, + ) + + expect(container.getByText('Here is my label')).toBeTruthy() + expect(container.getByText('some type')).toBeTruthy() + expect(container.getByTestId('safe-list-add-owner-icon')).toBeTruthy() + expect(container.getByText('Left node')).toBeTruthy() + + expect(container).toMatchSnapshot() + }) +}) + +describe('SafeListItem.Header', () => { + it('should render the default markup', () => { + const { getByText } = render() + + expect(getByText('any title for your header here')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/SafeListItem/SafeListItem.tsx b/apps/mobile/src/components/SafeListItem/SafeListItem.tsx new file mode 100644 index 000000000..b0361a005 --- /dev/null +++ b/apps/mobile/src/components/SafeListItem/SafeListItem.tsx @@ -0,0 +1,115 @@ +import React from 'react' +import { Container } from '../Container' +import { Text, Theme, ThemeName, View } 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' + +interface SafeListItemProps { + type?: string + label: string | React.ReactNode + icon?: IconProps['name'] + children?: React.ReactNode + rightNode?: React.ReactNode + leftNode?: React.ReactNode + bordered?: boolean + transparent?: boolean + spaced?: boolean + inQueue?: boolean + executionInfo?: Transaction['executionInfo'] + themeName?: ThemeName +} + +export function SafeListItem({ + type, + leftNode, + icon, + bordered, + spaced, + label, + transparent, + rightNode, + children, + inQueue, + executionInfo, + themeName, +}: SafeListItemProps) { + return ( + + + {leftNode} + + + {type && ( + + {icon && } + + {type} + + + )} + + {typeof label === 'string' ? ( + + {ellipsis(label, rightNode ? 21 : 30)} + + ) : ( + label + )} + + + + {inQueue && executionInfo && isMultisigExecutionInfo(executionInfo) ? ( + + + + + + {executionInfo?.confirmationsSubmitted}/{executionInfo?.confirmationsRequired} + + + } + themeName={ + executionInfo?.confirmationsRequired === executionInfo?.confirmationsSubmitted + ? 'badge_success_variant1' + : 'badge_warning_variant1' + } + /> + + + + ) : ( + rightNode + )} + + {children} + + ) +} + +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 new file mode 100644 index 000000000..e88f8e936 --- /dev/null +++ b/apps/mobile/src/components/SafeListItem/__snapshots__/SafeListItem.test.tsx.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SafeListItem should render a list item with a custom label template 1`] = ` + + + + + Left node + + + + +  + + + some type + + + + + Here is my label + + + + + + +`; diff --git a/apps/mobile/src/components/SafeListItem/index.tsx b/apps/mobile/src/components/SafeListItem/index.tsx new file mode 100644 index 000000000..267d451f0 --- /dev/null +++ b/apps/mobile/src/components/SafeListItem/index.tsx @@ -0,0 +1,2 @@ +import { SafeListItem } from './SafeListItem' +export { SafeListItem } diff --git a/apps/mobile/src/components/SafeTab/SafeTab.tsx b/apps/mobile/src/components/SafeTab/SafeTab.tsx new file mode 100644 index 000000000..60024a813 --- /dev/null +++ b/apps/mobile/src/components/SafeTab/SafeTab.tsx @@ -0,0 +1,37 @@ +import React, { ReactElement, useState } from 'react' +import { TabBarProps, Tabs } from 'react-native-collapsible-tab-view' +import { safeTabItem } from './types' +import { SafeTabBar } from './SafeTabBar' + +interface SafeTabProps { + renderHeader?: (props: TabBarProps) => ReactElement + headerHeight?: number + items: safeTabItem[] +} + +export function SafeTab({ renderHeader, headerHeight, items }: SafeTabProps) { + const [activeTab, setActiveTab] = useState(items[0].label) + + return ( + } + onTabChange={(event) => setActiveTab(event.tabName)} + initialTabName={items[0].label} + > + {items.map(({ label, Component }, index) => ( + + + + ))} + + ) +} + +const headerContainerStyle = { backgroundColor: '$background' } + +SafeTab.FlashList = Tabs.FlashList +SafeTab.FlatList = Tabs.FlatList +SafeTab.ScrollView = Tabs.ScrollView diff --git a/apps/mobile/src/components/SafeTab/SafeTabBar.tsx b/apps/mobile/src/components/SafeTab/SafeTabBar.tsx new file mode 100644 index 000000000..7e1cea788 --- /dev/null +++ b/apps/mobile/src/components/SafeTab/SafeTabBar.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { TabBarProps } from 'react-native-collapsible-tab-view' +import { TabName } from 'react-native-collapsible-tab-view/lib/typescript/src/types' +import { TouchableOpacity } from 'react-native-gesture-handler' +import { View, Text, useTheme } from 'tamagui' + +interface SafeTabBarProps { + setActiveTab: (name: string) => void + activeTab: string +} + +export const SafeTabBar = ({ + tabNames, + onTabPress, + activeTab, + setActiveTab, +}: TabBarProps & SafeTabBarProps) => { + const theme = useTheme() + + const activeButtonStyle = { + paddingBottom: 8, + borderBottomColor: theme.color?.get(), + borderBottomWidth: 2, + } + + const handleTabPressed = (name: string) => () => { + onTabPress(name) + setActiveTab(name) + } + + const isActiveTab = (name: string) => { + return activeTab === name + } + + return ( + + {tabNames.map((name) => ( + + + {name} + + + ))} + + ) +} diff --git a/apps/mobile/src/components/SafeTab/index.tsx b/apps/mobile/src/components/SafeTab/index.tsx new file mode 100644 index 000000000..a4a406690 --- /dev/null +++ b/apps/mobile/src/components/SafeTab/index.tsx @@ -0,0 +1,2 @@ +import { SafeTab } from './SafeTab' +export { SafeTab } diff --git a/apps/mobile/src/components/SafeTab/types.ts b/apps/mobile/src/components/SafeTab/types.ts new file mode 100644 index 000000000..817741794 --- /dev/null +++ b/apps/mobile/src/components/SafeTab/types.ts @@ -0,0 +1,4 @@ +export interface safeTabItem { + label: string + Component: React.FC +} diff --git a/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.stories.tsx b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.stories.tsx new file mode 100644 index 000000000..6a7b6ba0b --- /dev/null +++ b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { PendingTransactions } from '@/src/components/StatusBanners/PendingTransactions' +import { action } from '@storybook/addon-actions' + +const meta: Meta = { + title: 'StatusBanners/PendingTransactions', + component: PendingTransactions, + argTypes: { + number: { control: 'number' }, + }, + parameters: { actions: { argTypesRegex: '^on.*' } }, + args: { + fullWidth: false, + number: '5', + onPress: action('on-press'), + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} diff --git a/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.test.tsx b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.test.tsx new file mode 100644 index 000000000..ba945664d --- /dev/null +++ b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.test.tsx @@ -0,0 +1,27 @@ +import { render, userEvent } from '@/src/tests/test-utils' +import { PendingTransactions } from '.' + +describe('PendingTransactions', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render the default markup', async () => { + const user = userEvent.setup() + const mockedFn = jest.fn() + + const { getByText } = render() + + expect(getByText('2')).toBeTruthy() + + await user.press(getByText('Pending Transactions')) + + expect(mockedFn).toHaveBeenCalled() + }) + + it('should render the pending transactions in fullWidth layout', () => { + const container = render() + + expect(container).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.tsx b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.tsx new file mode 100644 index 000000000..bfcd6b566 --- /dev/null +++ b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { Spinner } from 'tamagui' + +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { Badge } from '@/src/components/Badge' + +import { Alert } from '../../Alert' + +interface Props { + number: string + fullWidth?: boolean + onPress: () => void + isLoading?: boolean +} + +export const PendingTransactions = ({ number, isLoading, fullWidth, onPress }: Props) => { + 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 new file mode 100644 index 000000000..edb96da7f --- /dev/null +++ b/apps/mobile/src/components/StatusBanners/PendingTransactions/__snapshots__/PendingTransactions.test.tsx.snap @@ -0,0 +1,158 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PendingTransactions should render the pending transactions in fullWidth layout 1`] = ` + + + + + + + + 2 + + + + + + Pending Transactions + + + + +  + + + + + + +`; diff --git a/apps/mobile/src/components/StatusBanners/PendingTransactions/index.tsx b/apps/mobile/src/components/StatusBanners/PendingTransactions/index.tsx new file mode 100644 index 000000000..2ec0f893f --- /dev/null +++ b/apps/mobile/src/components/StatusBanners/PendingTransactions/index.tsx @@ -0,0 +1,2 @@ +import { PendingTransactions } from './PendingTransactions' +export { PendingTransactions } diff --git a/apps/mobile/src/components/Tab/TabNameContext.tsx b/apps/mobile/src/components/Tab/TabNameContext.tsx new file mode 100644 index 000000000..223eb8edb --- /dev/null +++ b/apps/mobile/src/components/Tab/TabNameContext.tsx @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react' + +export const TabNameContext = createContext<{ tabName: string }>({ tabName: '' }) + +export const useTabNameContext = () => { + const context = useContext(TabNameContext) + if (!context) { + throw new Error('useTabNameContext must be inside a TabNameContext') + } + return context +} diff --git a/apps/mobile/src/components/Title/LargeHeaderTitle.tsx b/apps/mobile/src/components/Title/LargeHeaderTitle.tsx new file mode 100644 index 000000000..3c2e00b07 --- /dev/null +++ b/apps/mobile/src/components/Title/LargeHeaderTitle.tsx @@ -0,0 +1,6 @@ +import { SizableText, styled } from 'tamagui' + +export const LargeHeaderTitle = styled(SizableText, { + size: '$9', + fontWeight: 600, +}) diff --git a/apps/mobile/src/components/Title/NavBarTitle.tsx b/apps/mobile/src/components/Title/NavBarTitle.tsx new file mode 100644 index 000000000..20665e68d --- /dev/null +++ b/apps/mobile/src/components/Title/NavBarTitle.tsx @@ -0,0 +1,6 @@ +import { SizableText, styled } from 'tamagui' + +export const NavBarTitle = styled(SizableText, { + size: '$5', + fontWeight: 600, +}) diff --git a/apps/mobile/src/components/Title/SectionTitle.tsx b/apps/mobile/src/components/Title/SectionTitle.tsx new file mode 100644 index 000000000..4e1869116 --- /dev/null +++ b/apps/mobile/src/components/Title/SectionTitle.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { GetThemeValueForKey, Text, View } from 'tamagui' +import { LargeHeaderTitle } from './LargeHeaderTitle' + +interface SectionTitleProps { + title: string + description: string + paddingHorizontal?: GetThemeValueForKey<'paddingHorizontal'> +} + +export function SectionTitle({ title, description, paddingHorizontal = '$3' }: SectionTitleProps) { + return ( + + + {title} + + + {description} + + ) +} diff --git a/apps/mobile/src/components/Title/Title.test.tsx b/apps/mobile/src/components/Title/Title.test.tsx new file mode 100644 index 000000000..a8060c051 --- /dev/null +++ b/apps/mobile/src/components/Title/Title.test.tsx @@ -0,0 +1,19 @@ +import { render } from '@/src/tests/test-utils' +import { LargeHeaderTitle } from './LargeHeaderTitle' +import { NavBarTitle } from './NavBarTitle' + +describe('LargeHeaderTitle', () => { + it('should render the default markup', () => { + const { getByText } = render(Here is my large header) + + expect(getByText('Here is my large header')).toBeTruthy() + }) +}) + +describe('NavBarTitle', () => { + it('should render the default markup', () => { + const { getByText } = render(Here is my NabBarTitle) + + expect(getByText('Here is my NabBarTitle')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/Title/index.ts b/apps/mobile/src/components/Title/index.ts new file mode 100644 index 000000000..d9b67e6b4 --- /dev/null +++ b/apps/mobile/src/components/Title/index.ts @@ -0,0 +1,5 @@ +import { LargeHeaderTitle } from './LargeHeaderTitle' +import { NavBarTitle } from './NavBarTitle' +import { SectionTitle } from './SectionTitle' + +export { LargeHeaderTitle, NavBarTitle, SectionTitle } diff --git a/apps/mobile/src/components/TxInfo/TxInfo.tsx b/apps/mobile/src/components/TxInfo/TxInfo.tsx new file mode 100644 index 000000000..8921e3865 --- /dev/null +++ b/apps/mobile/src/components/TxInfo/TxInfo.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { TransactionInfoType } from '@safe-global/store/gateway/types' +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' +import { TxSettingsCard } from '@/src/components/transactions-list/Card/TxSettingsCard' +import { + isCancellationTxInfo, + isCreationTxInfo, + isCustomTxInfo, + isMultiSendTxInfo, + isSettingsChangeTxInfo, + isSwapOrderTxInfo, + isTransferTxInfo, +} 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 { TxCreationCard } from '@/src/components/transactions-list/Card/TxCreationCard' + +interface TxInfoProps { + tx: Transaction + bordered?: boolean + inQueue?: boolean +} + +function TxInfoComponent({ tx, bordered, inQueue }: TxInfoProps) { + const txType = useTransactionType(tx) + + const txInfo = tx.txInfo + if (isTransferTxInfo(txInfo)) { + return ( + + ) + } + + if (isSettingsChangeTxInfo(txInfo)) { + return + } + + if (isMultiSendTxInfo(txInfo) && tx.txInfo.type === TransactionInfoType.CUSTOM) { + return ( + + ) + } + + if (isMultiSendTxInfo(txInfo) && tx.safeAppInfo) { + return ( + + ) + } + + if (isCreationTxInfo(txInfo)) { + return + } + + if (isCancellationTxInfo(txInfo)) { + return + } + + if (isMultiSendTxInfo(txInfo) || isCustomTxInfo(txInfo)) { + return ( + + ) + } + + if (isSwapOrderTxInfo(txInfo)) { + return + } + + return <> +} + +export const TxInfo = React.memo(TxInfoComponent, (prevProps, nextProps) => { + return prevProps.tx.txHash === nextProps.tx.txHash +}) diff --git a/apps/mobile/src/components/TxInfo/index.tsx b/apps/mobile/src/components/TxInfo/index.tsx new file mode 100644 index 000000000..d683ece41 --- /dev/null +++ b/apps/mobile/src/components/TxInfo/index.tsx @@ -0,0 +1,3 @@ +import { TxInfo } from './TxInfo' + +export { TxInfo } diff --git a/apps/mobile/src/components/navigation/TabBarIcon.test.tsx b/apps/mobile/src/components/navigation/TabBarIcon.test.tsx new file mode 100644 index 000000000..2f8245e88 --- /dev/null +++ b/apps/mobile/src/components/navigation/TabBarIcon.test.tsx @@ -0,0 +1,10 @@ +import { render } from '@/src/tests/test-utils' +import { TabBarIcon } from './TabBarIcon' + +describe('TabBarIcon', () => { + it('should render the default markup', () => { + const { getByTestId } = render() + + expect(getByTestId('tab-bar-add-owner-icon')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/navigation/TabBarIcon.tsx b/apps/mobile/src/components/navigation/TabBarIcon.tsx new file mode 100644 index 000000000..61c74d38b --- /dev/null +++ b/apps/mobile/src/components/navigation/TabBarIcon.tsx @@ -0,0 +1,5 @@ +import { SafeFontIcon, IconProps } from '@/src/components/SafeFontIcon/SafeFontIcon' + +export function TabBarIcon({ name, ...rest }: IconProps) { + return +} diff --git a/apps/mobile/src/components/navigation/index.ts b/apps/mobile/src/components/navigation/index.ts new file mode 100644 index 000000000..98a0c1e78 --- /dev/null +++ b/apps/mobile/src/components/navigation/index.ts @@ -0,0 +1,2 @@ +import { TabBarIcon } from './TabBarIcon' +export { TabBarIcon } diff --git a/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.stories.tsx new file mode 100644 index 000000000..f2ee7bebe --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { AccountCard } from '@/src/components/transactions-list/Card/AccountCard' +import { mockedActiveSafeInfo, mockedChains } from '@/src/store/constants' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { Address } from '@/src/types/address' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' + +const meta: Meta = { + title: 'TransactionsList/AccountCard', + component: AccountCard, + argTypes: {}, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + name: 'This is my account', + chains: mockedChains as unknown as Chain[], + owners: 5, + balance: mockedActiveSafeInfo.fiatTotal, + address: mockedActiveSafeInfo.address.value as Address, + threshold: 2, + }, + parameters: { + layout: 'fullscreen', + }, + render: ({ ...args }) => } />, +} + +export const TruncatedAccount: Story = { + args: { + name: 'This is my account with a very long text in one more test', + chains: mockedChains as unknown as Chain[], + owners: 5, + balance: mockedActiveSafeInfo.fiatTotal, + address: mockedActiveSafeInfo.address.value as Address, + threshold: 2, + }, + parameters: { + layout: 'fullscreen', + }, + render: ({ ...args }) => } />, +} 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 new file mode 100644 index 000000000..3a5243a62 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.test.tsx @@ -0,0 +1,45 @@ +import { render } from '@/src/tests/test-utils' +import { AccountCard } from './AccountCard' +import { mockedActiveSafeInfo, mockedChains } from '@/src/store/constants' +import { Address } from '@/src/types/address' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +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 container = render( + , + ) + expect(container.getByTestId('threshold-info-badge')).toBeVisible() + expect(container.getByText('2/5')).toBeDefined() + expect(container.getByText(`$${mockedActiveSafeInfo.fiatTotal}`)).toBeDefined() + expect(container.getByText(accountName)).toBeDefined() + }) + + it('should truncate the account information when they are very long', () => { + const longAccountName = 'This is my account with a very very long text' + const longBalance = '21312321312213213121221312321312312' + const container = render( + , + ) + expect(container.getByTestId('threshold-info-badge')).toBeVisible() + expect(container.getByText('2/5')).toBeDefined() + expect(container.getByText(`$${ellipsis(longBalance, 14)}`)).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 new file mode 100644 index 000000000..533a30671 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { SafeListItem } from '@/src/components/SafeListItem' +import { ellipsis } from '@/src/utils/formatters' +import { IdenticonWithBadge } from '@/src/features/Settings/components/IdenticonWithBadge' +import { Address } from '@/src/types/address' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { ChainsDisplay } from '@/src/components/ChainsDisplay' + +interface AccountCardProps { + name: string | Address + balance: string + address: Address + owners: number + threshold: number + rightNode?: string | React.ReactNode + leftNode?: React.ReactNode + chains?: Chain[] + spaced?: boolean +} + +export function AccountCard({ + name, + chains, + spaced, + owners, + leftNode, + balance, + address, + threshold, + rightNode, +}: AccountCardProps) { + return ( + + + {ellipsis(name, 18)} + + + ${ellipsis(balance, 14)} + + + } + leftNode={ + + {leftNode} + 9 ? 8 : 12} + address={address} + badgeContent={`${threshold}/${owners}`} + /> + + } + rightNode={ + + {chains && } + {rightNode} + + } + transparent + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/AccountCard/index.ts b/apps/mobile/src/components/transactions-list/Card/AccountCard/index.ts new file mode 100644 index 000000000..d692abb08 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/AccountCard/index.ts @@ -0,0 +1 @@ +export { AccountCard } from './AccountCard' diff --git a/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.test.tsx new file mode 100644 index 000000000..d6e5b5ef3 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.test.tsx @@ -0,0 +1,18 @@ +import { render } from '@/src/tests/test-utils' +import { AssetsCard } from '.' + +describe('AssetsCard', () => { + it('should render the default markup', () => { + const { getByText } = render() + + expect(getByText('Ether')).toBeTruthy() + expect(getByText('some info about the token')).toBeTruthy() + }) + + it('should render the price of the asset', () => { + const { getByText } = render() + + expect(getByText('Ether')).toBeTruthy() + expect(getByText('some info about the token')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.tsx b/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.tsx new file mode 100644 index 000000000..0ea50ba34 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { SafeListItem } from '@/src/components/SafeListItem' +import { Logo } from '@/src/components/Logo' +import { ellipsis } from '@/src/utils/formatters' + +interface AssetsCardProps { + name: string + description: string + logoUri?: string | null + rightNode?: string | React.ReactNode + accessibilityLabel?: string + imageBackground?: string + transparent?: boolean +} + +export function AssetsCard({ + name, + description, + imageBackground, + logoUri, + accessibilityLabel, + rightNode, + transparent = true, +}: AssetsCardProps) { + return ( + + + {name} + + + {description} + + + } + transparent={transparent} + leftNode={} + rightNode={ + typeof rightNode === 'string' ? ( + + {ellipsis(rightNode, 10)} + + ) : ( + rightNode + ) + } + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/AssetsCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/AssetsCard/index.tsx new file mode 100644 index 000000000..737269b43 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/AssetsCard/index.tsx @@ -0,0 +1,2 @@ +import { AssetsCard } from './AssetsCard' +export { AssetsCard } diff --git a/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.test.tsx new file mode 100644 index 000000000..ef5cd89d4 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.test.tsx @@ -0,0 +1,24 @@ +import { render } from '@/src/tests/test-utils' +import { SignersCard } from '.' +import { Text } from 'tamagui' +import { shortenAddress } from '@/src/utils/formatters' + +const fakeAddress = '0x0000000000000000000000000000000000000000' + +describe('AssetsCard', () => { + it('should render the default markup', () => { + const { getByText } = render() + + expect(getByText('Ether')).toBeTruthy() + expect(getByText(shortenAddress(fakeAddress))).toBeTruthy() + }) + + it('should render the rightNode of the asset', () => { + const { getByText } = render( + rightNode} address={fakeAddress} />, + ) + + expect(getByText('Nevinhoso')).toBeTruthy() + expect(getByText('rightNode')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.tsx b/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.tsx new file mode 100644 index 000000000..b29e7c6bd --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { Identicon } from '@/src/components/Identicon' +import { SafeListItem } from '@/src/components/SafeListItem' +import { EthAddress } from '@/src/components/EthAddress' + +type SignersCardProps = { + name: string + address: `0x${string}` + rightNode?: React.ReactNode +} + +export function SignersCard({ name, address, rightNode }: SignersCardProps) { + return ( + + + {name} + + + + + } + leftNode={ + + + + } + rightNode={rightNode} + /> + ) +} + +export default SignersCard diff --git a/apps/mobile/src/components/transactions-list/Card/SignersCard/index.ts b/apps/mobile/src/components/transactions-list/Card/SignersCard/index.ts new file mode 100644 index 000000000..8eea500c0 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/SignersCard/index.ts @@ -0,0 +1 @@ +export { SignersCard } from './SignersCard' diff --git a/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.stories.tsx new file mode 100644 index 000000000..52e6c4ad6 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TxBatchCard } from '@/src/components/transactions-list/Card/TxBatchCard' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { MultiSend, TransactionInfoType } from '@safe-global/store/gateway/types' + +const meta: Meta = { + title: 'TransactionsList/TxBatchCard', + component: TxBatchCard, + argTypes: { + bordered: { + description: 'Define if you want a border on the transaction', + control: { + type: 'boolean', + }, + }, + }, + args: { + bordered: false, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.CUSTOM, + actionCount: 2, + to: { + value: '', + logoUri: 'https://safe-transaction-assets.safe.global/safe_apps/408a90a2-170c-485a-93bb-daa843298f11/icon.png', + name: 'Gnosis Bridge', + }, + }) as MultiSend, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} 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 new file mode 100644 index 000000000..6031fc4d8 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.test.tsx @@ -0,0 +1,28 @@ +import { render } from '@/src/tests/test-utils' +import { TxBatchCard } from '.' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { MultiSend, TransactionInfoType } from '@safe-global/store/gateway/types' + +describe('TxBatchCard', () => { + it('should render the default markup', () => { + const container = render( + , + ) + + expect(container.getByText('Batch')).toBeTruthy() + expect(container.getByText('2 actions')).toBeTruthy() + expect(container).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.tsx new file mode 100644 index 000000000..c82326cd3 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Avatar, 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' + +interface 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 + + return ( + + {logoUri && } + + + + + + + + } + /> + ) +} 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 new file mode 100644 index 000000000..b1905cb63 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/__snapshots__/TxBatchCard.test.tsx.snap @@ -0,0 +1,206 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TxBatchCard should render the default markup 1`] = ` + + + + + + + + + + +  + + + + + + + +  + + + Batch + + + + 2 actions + + + + + +`; diff --git a/apps/mobile/src/components/transactions-list/Card/TxBatchCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/index.tsx new file mode 100644 index 000000000..17d120587 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/index.tsx @@ -0,0 +1,2 @@ +import { TxBatchCard } from './TxBatchCard' +export { TxBatchCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/TxConflictingCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/TxConflictingCard.tsx new file mode 100644 index 000000000..a0ca3f53d --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/TxConflictingCard.tsx @@ -0,0 +1,32 @@ +import React 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' + +interface TxConflictingCard { + transactions: TransactionQueuedItem[] + inQueue?: boolean +} + +function TxConflictingComponent({ transactions, inQueue }: TxConflictingCard) { + return ( + <> + + + + + + {transactions.map((item, index) => ( + + + + ))} + + + ) +} + +export const TxConflictingCard = React.memo(TxConflictingComponent, (prevProps, nextProps) => { + return prevProps.transactions.length === nextProps.transactions.length +}) diff --git a/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/index.tsx new file mode 100644 index 000000000..5e744b182 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/index.tsx @@ -0,0 +1,2 @@ +import { TxConflictingCard } from './TxConflictingCard' +export { TxConflictingCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.stories.tsx new file mode 100644 index 000000000..82acbdd37 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TxContractInteractionCard } from '@/src/components/transactions-list/Card/TxContractInteractionCard' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { CustomTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionInfoType } from '@safe-global/store/gateway/types' + +const meta: Meta = { + title: 'TransactionsList/TxContractInteractionCard', + component: TxContractInteractionCard, + argTypes: { + bordered: { + description: 'Define if you want a border on the transaction', + control: { + type: 'boolean', + }, + }, + }, + args: { + bordered: false, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.CUSTOM, + to: { + value: '0x0000', + name: 'CryptoNevinhosos', + logoUri: '', + }, + }) as CustomTransactionInfo, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} 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 new file mode 100644 index 000000000..99725f1fd --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.test.tsx @@ -0,0 +1,44 @@ +import { render } from '@/src/tests/test-utils' +import { TxContractInteractionCard } from '.' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { TransactionInfoType } from '@safe-global/store/gateway/types' +import { CustomTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +describe('TxContractInteractionCard', () => { + it('should render the default markup', () => { + const { getByText, getByLabelText } = render( + , + ) + + expect(getByText('CryptoNevinhosos')).toBeTruthy() + expect(getByLabelText('CryptoNevinhosos')).toBeTruthy() + }) + + it('should render a fallback in the label and icon if the contract is missing name and logoUri', () => { + const { getByText } = render( + , + ) + + expect(getByText('Contract interaction')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.tsx new file mode 100644 index 000000000..75c08f7dd --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { Avatar, Text, Theme, View } 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' + +interface TxContractInteractionCardProps { + bordered?: boolean + txInfo: CustomTransactionInfo | MultiSend + inQueue?: boolean + executionInfo?: Transaction['executionInfo'] +} + +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}} + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/index.tsx new file mode 100644 index 000000000..792582ea8 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/index.tsx @@ -0,0 +1,2 @@ +import { TxContractInteractionCard } from './TxContractInteractionCard' +export { TxContractInteractionCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.stories.tsx new file mode 100644 index 000000000..1f8ecf6dd --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TxCreationCard } from '@/src/components/transactions-list/Card/TxCreationCard' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { type CreationTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionInfoType } from '@safe-global/store/gateway/types' + +const meta: Meta = { + title: 'TransactionsList/TxCreationCard', + component: TxCreationCard, + argTypes: { + bordered: { + description: 'Define if you want a border on the transaction', + control: { + type: 'boolean', + }, + }, + }, + args: { + bordered: false, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.CREATION, + creator: { + name: 'Nevinha', + logoUri: '', + value: '0xas123da123sdasdsd001230sdf1sdf12sd12f', + }, + }) as CreationTransactionInfo, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} 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 new file mode 100644 index 000000000..7d2045992 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.test.tsx @@ -0,0 +1,27 @@ +import { render } from '@/src/tests/test-utils' +import { TxCreationCard } from '.' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { TransactionInfoType } from '@safe-global/store/gateway/types' +import { CreationTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +describe('TxCreationCard', () => { + it('should render the default markup', () => { + const { getByText } = render( + , + ) + + expect(getByText('Safe Account created')).toBeTruthy() + expect(getByText('Created by: 0xas12...d12f')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.tsx new file mode 100644 index 000000000..4a052a937 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.tsx @@ -0,0 +1,32 @@ +import React from 'react' +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' + +interface TxCreationCardProps { + txInfo: CreationTransactionInfo + bordered?: boolean + inQueue?: boolean + executionInfo?: Transaction['executionInfo'] +} + +export function TxCreationCard({ txInfo, executionInfo, bordered, inQueue }: TxCreationCardProps) { + return ( + + + + + + } + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxCreationCard/index.ts b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/index.ts new file mode 100644 index 000000000..5203e4a31 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/index.ts @@ -0,0 +1,2 @@ +import { TxCreationCard } from './TxCreationCard' +export { TxCreationCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.stories.tsx new file mode 100644 index 000000000..409cbf6be --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { mockERC20Transfer, mockListItemByType, mockNFTTransfer } from '@/src/tests/mocks' +import { TransactionItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionListItemType, TransactionStatus } from '@safe-global/store/gateway/types' +import { TxGroupedCard } from '.' + +const meta: Meta = { + title: 'TransactionsList/TxGroupedCard', + component: TxGroupedCard, + argTypes: {}, + args: { + transactions: [ + { + ...mockListItemByType(TransactionListItemType.TRANSACTION), + transaction: { + id: 'id', + timestamp: 123123, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockERC20Transfer, + txHash: '0x0000000', + }, + } as TransactionItem, + { + ...mockListItemByType(TransactionListItemType.TRANSACTION), + transaction: { + id: 'id', + timestamp: 123123, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockNFTTransfer, + txHash: '0x0000000', + }, + } as TransactionItem, + ], + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} diff --git a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx new file mode 100644 index 000000000..1feef55b6 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx @@ -0,0 +1,69 @@ +import { render } from '@/src/tests/test-utils' +import { TxGroupedCard } from '.' +import { mockERC20Transfer, mockListItemByType, mockNFTTransfer, mockSwapTransfer } from '@/src/tests/mocks' +import { TransactionListItemType, TransactionStatus } from '@safe-global/store/gateway/types' +import { TransactionItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +jest.mock('@/src/store/chains', () => { + const actualModule = jest.requireActual('@/src/store/chains') // Import the real module + return { + ...actualModule, + selectChainById: jest.fn().mockImplementation(() => ({ + decimals: 8, + logoUri: 'http://safe.com/logo.png', + name: 'mocked currency', + symbol: 'MCC', + })), + } +}) + +describe('TxGroupedCard', () => { + it('should render the default markup', () => { + const { getAllByTestId } = render( + , + ) + + expect(getAllByTestId('tx-group-info')).toHaveLength(2) + }) + + it('should render a gropuped swap transaction', () => { + const container = render( + , + ) + + expect(container.getByText('Swap order settlement')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.tsx new file mode 100644 index 000000000..0cebc6e7c --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { Theme, View } from 'tamagui' +import { SafeListItem } from '@/src/components/SafeListItem' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { TxInfo } from '@/src/components/TxInfo' +import { getOrderClass } from '@/src/hooks/useTransactionType' +import { isSwapTransferOrderTxInfo } from '@/src/utils/transaction-guards' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' +import { TransactionQueuedItem, TransactionItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +interface TxGroupedCard { + transactions: (TransactionItem | TransactionQueuedItem)[] + inQueue?: boolean +} + +const orderClassTitles: Record = { + limit: 'Limit order settlement', + twap: 'TWAP order settlement', + liquidity: 'Liquidity order settlement', + market: 'Swap order settlement', +} + +const getSettlementOrderTitle = (order: OrderTransactionInfo): string => { + const orderClass = getOrderClass(order) + return orderClassTitles[orderClass] || orderClassTitles['market'] +} + +function TxGroupedCardComponent({ transactions, inQueue }: TxGroupedCard) { + const firstTxInfo = transactions[0].transaction.txInfo + const isSwapTransfer = isSwapTransferOrderTxInfo(firstTxInfo) + const label = isSwapTransfer ? getSettlementOrderTitle(firstTxInfo) : 'Bulk transactions' + + return ( + + + + + + } + rightNode={} + > + + {transactions.map((item, index) => ( + + + + ))} + + + ) +} + +export const TxGroupedCard = React.memo(TxGroupedCardComponent, (prevProps, nextProps) => { + return prevProps.transactions.length === nextProps.transactions.length +}) diff --git a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/index.tsx new file mode 100644 index 000000000..94f626f08 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/index.tsx @@ -0,0 +1,2 @@ +import { TxGroupedCard } from './TxGroupedCard' +export { TxGroupedCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.stories.tsx new file mode 100644 index 000000000..5901b682d --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TxRejectionCard } from '@/src/components/transactions-list/Card/TxRejectionCard' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { Cancellation } from '@safe-global/store/gateway/types' + +const meta: Meta = { + title: 'TransactionsList/TxRejectionCard', + component: TxRejectionCard, + argTypes: { + bordered: { + description: 'Define if you want a border on the transaction', + control: { + type: 'boolean', + }, + }, + }, + args: { + bordered: false, + txInfo: mockTransferWithInfo({}) as Cancellation, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} 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 new file mode 100644 index 000000000..c11f4f3be --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.test.tsx @@ -0,0 +1,12 @@ +import { render } from '@/src/tests/test-utils' +import { TxRejectionCard } from '.' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { Cancellation } from '@safe-global/store/gateway/types' + +describe('TxRejectionCard', () => { + it('should render the default markup', () => { + const { getByText } = render() + + 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 new file mode 100644 index 000000000..afff4d9f7 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { View } from 'tamagui' +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' + +interface TxRejectionCardProps { + bordered?: boolean + txInfo: Cancellation + inQueue?: boolean + executionInfo?: Transaction['executionInfo'] +} + +export function TxRejectionCard({ bordered, executionInfo, txInfo, inQueue }: TxRejectionCardProps) { + return ( + + + + } + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/index.tsx new file mode 100644 index 000000000..d72615fec --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/index.tsx @@ -0,0 +1,2 @@ +import { TxRejectionCard } from './TxRejectionCard' +export { TxRejectionCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.stories.tsx new file mode 100644 index 000000000..57d77ed48 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TxSafeAppCard } from '@/src/components/transactions-list/Card/TxSafeAppCard' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { MultiSend } from '@safe-global/store/gateway/types' + +const meta: Meta = { + title: 'TransactionsList/TxSafeAppCard', + component: TxSafeAppCard, + argTypes: { + bordered: { + description: 'Define if you want a border on the transaction', + control: { + type: 'boolean', + }, + }, + }, + args: { + bordered: false, + safeAppInfo: { + name: 'Transaction Builder', + url: 'http://something.com', + logoUri: 'https://safe-transaction-assets.safe.global/safe_apps/29/icon.png', + }, + txInfo: mockTransferWithInfo({}) as MultiSend, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} 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 new file mode 100644 index 000000000..051785e26 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.test.tsx @@ -0,0 +1,39 @@ +import { render } from '@/src/tests/test-utils' +import { TxSafeAppCard } from '.' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { MultiSend } from '@safe-global/store/gateway/types' + +describe('TxSafeAppCard', () => { + it('should render the default markup', () => { + const { getByText } = render( + , + ) + + expect(getByText('Transaction Builder')).toBeTruthy() + expect(getByText('Safe app')).toBeTruthy() + }) + + it('should render a fallback if no image url is provided', () => { + const { getByText, getByTestId, queryByTestId } = render( + , + ) + + expect(getByText('Transaction Builder')).toBeTruthy() + expect(getByText('Safe app')).toBeTruthy() + expect(queryByTestId('safe-app-image')).not.toBeTruthy() + expect(getByTestId('safe-app-fallback')).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 new file mode 100644 index 000000000..6b4850f8f --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { Avatar, Text, 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 { SafeAppInfo, Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +interface TxSafeAppCardProps { + safeAppInfo: SafeAppInfo + txInfo: MultiSend + bordered?: boolean + inQueue?: boolean + executionInfo?: Transaction['executionInfo'] +} + +export function TxSafeAppCard({ safeAppInfo, executionInfo, txInfo, inQueue, bordered }: TxSafeAppCardProps) { + return ( + + {safeAppInfo.logoUri && ( + + )} + + + + + + + + } + rightNode={{txInfo.methodName}} + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/index.tsx new file mode 100644 index 000000000..918ccd9f5 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/index.tsx @@ -0,0 +1,2 @@ +import { TxSafeAppCard } from './TxSafeAppCard' +export { TxSafeAppCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingCard.stories.tsx new file mode 100644 index 000000000..3eb6822be --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingCard.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TxSettingsCard } from '@/src/components/transactions-list/Card/TxSettingsCard' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { SettingsChangeTransaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionInfoType } from '@safe-global/store/gateway/types' + +const meta: Meta = { + title: 'TransactionsList/TxSettingsCard', + component: TxSettingsCard, + argTypes: {}, + args: { + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.SETTINGS_CHANGE, + }) as SettingsChangeTransaction, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} 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 new file mode 100644 index 000000000..d6e9a98a8 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingCard.test.tsx @@ -0,0 +1,21 @@ +import { render } from '@/src/tests/test-utils' +import { TxSettingsCard } from '.' +import { mockTransferWithInfo } from '@/src/tests/mocks' +import { TransactionInfoType } from '@safe-global/store/gateway/types' +import { SettingsChangeTransaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +describe('TxSettingCard', () => { + it('should render the default markup', () => { + const container = render( + , + ) + + expect(container).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingsCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingsCard.tsx new file mode 100644 index 000000000..d0dbab637 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingsCard.tsx @@ -0,0 +1,35 @@ +import React 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' + +interface TxSettingsCardProps { + txInfo: SettingsChangeTransaction + bordered?: boolean + inQueue?: boolean + executionInfo?: Transaction['executionInfo'] +} + +export function TxSettingsCard({ txInfo, bordered, executionInfo, inQueue }: TxSettingsCardProps) { + const isDeleteGuard = txInfo.settingsInfo?.type === SettingsInfoType.DELETE_GUARD + const label = isDeleteGuard ? 'deleteGuard' : txInfo.dataDecoded.method + + return ( + + + + + + } + /> + ) +} 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 new file mode 100644 index 000000000..700971ad3 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/__snapshots__/TxSettingCard.test.tsx.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TxSettingCard should render the default markup 1`] = ` + + + + + +  + + + + + + Settings change + + + + mockMethod + + + + + +`; diff --git a/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/index.tsx new file mode 100644 index 000000000..b3c254bd2 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/index.tsx @@ -0,0 +1,2 @@ +import { TxSettingsCard } from './TxSettingsCard' +export { TxSettingsCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.stories.tsx new file mode 100644 index 000000000..47ab945cb --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TxSwapCard } from '@/src/components/transactions-list/Card/TxSwapCard' +import { mockSwapTransfer } from '@/src/tests/mocks' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' + +const meta: Meta = { + title: 'TransactionsList/TxSwapCard', + component: TxSwapCard, + args: { + txInfo: mockSwapTransfer as OrderTransactionInfo, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} 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 new file mode 100644 index 000000000..94e441427 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.test.tsx @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..bc0121002 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.tsx @@ -0,0 +1,63 @@ +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 new file mode 100644 index 000000000..ceb175fea --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/__snapshots__/TxSwapCard.test.tsx.snap @@ -0,0 +1,290 @@ +// 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 new file mode 100644 index 000000000..4c95c2e35 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/index.tsx @@ -0,0 +1,2 @@ +import { TxSwapCard } from './TxSwapCard' +export { TxSwapCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.stories.tsx new file mode 100644 index 000000000..ee710c868 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TxTokenCard } from '@/src/components/transactions-list/Card/TxTokenCard' +import { mockERC20Transfer, mockNFTTransfer } from '@/src/tests/mocks' +import { TransactionStatus } from '@safe-global/store/gateway/types' +import { type TransferTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const meta: Meta = { + title: 'TransactionsList/TxTokenCard', + component: TxTokenCard, + argTypes: { + bordered: { + description: 'Define if you want a border on the transaction', + control: { + type: 'boolean', + }, + }, + }, + args: { + bordered: false, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + txStatus: TransactionStatus.SUCCESS, + txInfo: mockERC20Transfer as TransferTransactionInfo, + }, +} +export const NFT: Story = { + args: { + txStatus: TransactionStatus.SUCCESS, + txInfo: mockNFTTransfer as TransferTransactionInfo, + }, +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx new file mode 100644 index 000000000..c66158c3d --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx @@ -0,0 +1,102 @@ +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 { 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' + +interface TxTokenCardProps { + bordered?: boolean + 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: '', + } +} + +export function TxTokenCard({ bordered, inQueue, txStatus, executionInfo, txInfo }: 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 isERC721 = isERC721Transfer(txInfo.transferInfo) + const isOutgoing = txInfo.direction === TransferDirection.OUTGOING + + return ( + } + rightNode={ + + + {isOutgoing ? '-' : '+'} {ellipsis(value, 8)} {!isERC721 && tokenSymbol} + + + } + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/index.tsx new file mode 100644 index 000000000..122cb0ab2 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/index.tsx @@ -0,0 +1,2 @@ +import { TxTokenCard } from './TxTokenCard' +export { TxTokenCard } diff --git a/apps/mobile/src/config/constants.ts b/apps/mobile/src/config/constants.ts new file mode 100644 index 000000000..158774b6b --- /dev/null +++ b/apps/mobile/src/config/constants.ts @@ -0,0 +1,15 @@ +import Constants from 'expo-constants' +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 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 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' +export const GATEWAY_URL = isProduction ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING diff --git a/apps/mobile/src/config/ethers.ts b/apps/mobile/src/config/ethers.ts new file mode 100644 index 000000000..52a030eeb --- /dev/null +++ b/apps/mobile/src/config/ethers.ts @@ -0,0 +1,42 @@ +//TODO: the interface of ethersjs register is not compatible +// with the interface of suggested crypto functions +// that is why we do casting. +// reference: https://docs.ethers.org/v6/cookbook/react-native/ + +import { BytesLike, 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() +}) + +ethers.pbkdf2.register( + ( + password: Uint8Array, + salt: Uint8Array, + iterations: number, + keylen: number, + algo: 'sha256' | 'sha512', + ): BytesLike => { + return crypto.pbkdf2Sync(password, salt, iterations, keylen, algo) as unknown as BytesLike + }, +) + +ethers.sha256.register((data) => { + return crypto + .createHash('sha256') + .update(data as unknown as string) + .digest() +}) + +ethers.sha512.register((data) => { + return crypto + .createHash('sha512') + .update(data as unknown as string) + .digest() +}) diff --git a/apps/mobile/src/context/NotificationsContext.tsx b/apps/mobile/src/context/NotificationsContext.tsx new file mode 100644 index 000000000..e002d79ba --- /dev/null +++ b/apps/mobile/src/context/NotificationsContext.tsx @@ -0,0 +1,37 @@ +import React, { createContext, useContext, ReactNode } from 'react' + +import useNotifications from '@/src/hooks/useNotifications' +import { FirebaseMessagingTypes } from '@react-native-firebase/messaging' + +interface NotificationContextType { + isAppNotificationEnabled: boolean + fcmToken: string | null + remoteMessages: FirebaseMessagingTypes.RemoteMessage[] | [] +} + +const NotificationContext = createContext(undefined) + +export const useNotification = () => { + const context = useContext(NotificationContext) + if (!context) { + throw new Error('useNotification must be used within a NotificationProvider') + } + return context +} + +interface NotificationProviderProps { + children: ReactNode +} + +export const NotificationsProvider: React.FC = ({ children }) => { + /** + * Enables notifications for the app if the user has enabled them + */ + const { isAppNotificationEnabled, fcmToken, remoteMessages } = useNotifications() + + return ( + + {children} + + ) +} diff --git a/apps/mobile/src/features/Assets/Assets.container.tsx b/apps/mobile/src/features/Assets/Assets.container.tsx new file mode 100644 index 000000000..0cbfcecfb --- /dev/null +++ b/apps/mobile/src/features/Assets/Assets.container.tsx @@ -0,0 +1,43 @@ +import React, { useEffect } 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 = [ + { + label: 'Tokens', + Component: TokensContainer, + }, + { + label: `NFT's`, + 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 +} diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.stories.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.stories.tsx new file mode 100644 index 000000000..26b24209d --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.stories.tsx @@ -0,0 +1,64 @@ +import { AccountItem } from './AccountItem' +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' +import { Address } from '@/src/types/address' + +const meta: Meta = { + title: 'Assets/AccountItem', + component: AccountItem, + argTypes: {}, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + account: mockedActiveSafeInfo, + chains: mockedChains as unknown as Chain[], + activeAccount: '0x123', + onSelect: action('onSelect'), + }, + parameters: { + layout: 'fullscreen', + }, +} + +export const ActiveAccount: Story = { + args: { + account: mockedActiveSafeInfo, + chains: mockedChains as unknown as Chain[], + activeAccount: mockedActiveSafeInfo.address.value as Address, + onSelect: action('onSelect'), + }, + parameters: { + layout: 'fullscreen', + }, +} + +export const TruncatedAccountChains: Story = { + args: { + account: mockedActiveSafeInfo, + chains: [...mockedChains, ...mockedChains, ...mockedChains] as unknown as Chain[], + activeAccount: '0x12312', + onSelect: action('onSelect'), + }, + parameters: { + layout: 'fullscreen', + }, +} + +export const TruncatedActiveAccountChains: Story = { + args: { + account: mockedActiveSafeInfo, + chains: [...mockedChains, ...mockedChains, ...mockedChains] as unknown as Chain[], + activeAccount: mockedActiveSafeInfo.address.value as Address, + onSelect: action('onSelect'), + }, + parameters: { + layout: 'fullscreen', + }, +} diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx new file mode 100644 index 000000000..70b970a34 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx @@ -0,0 +1,120 @@ +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' + +const mockAccount = { + address: { value: '0x123' as `0x${string}`, name: 'Test Account' }, + threshold: 1, + owners: [{ value: '0x456' as `0x${string}` }], + fiatTotal: '1000', + chainId: '1', + queued: 0, +} + +const mockChains = [ + { + chainId: '1', + chainName: 'Ethereum', + shortName: 'eth', + description: 'Ethereum', + l2: false, + isTestnet: false, + nativeCurrency: { symbol: 'ETH', decimals: 18, name: 'Ether' }, + blockExplorerUriTemplate: { address: '', txHash: '', api: '' }, + transactionService: '', + theme: { backgroundColor: '', textColor: '' }, + gasPrice: [], + ensRegistryAddress: '', + features: [], + disabledWallets: [], + rpcUri: { authentication: '', value: '' }, + beaconChainExplorerUriTemplate: { address: '', api: '' }, + balancesProvider: '', + contractAddresses: {}, + publicRpcUri: { authentication: '', value: '' }, + safeAppsRpcUri: { authentication: '', value: '' }, + }, +] + +describe('AccountItem', () => { + const mockOnSelect = jest.fn() + const mockDrag = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders account details correctly', () => { + render( + , + ) + + expect(screen.getByText('Test Account')).toBeTruthy() + expect(screen.getByText('1/1')).toBeTruthy() + expect(screen.getByText('$1000')).toBeTruthy() + }) + + it('shows active state when account is selected', () => { + render( + , + ) + + const wrapper = screen.getByTestId('account-item-wrapper') + expect(wrapper.props.style.backgroundColor).toBe('#DCDEE0') + }) + + it('calls onSelect when pressed', () => { + render( + , + ) + + fireEvent.press(screen.getByTestId('account-item-wrapper')) + expect(mockOnSelect).toHaveBeenCalledWith(mockAccount.address.value) + }) + + it('enables drag functionality when provided', () => { + render( + , + ) + + fireEvent(screen.getByTestId('account-item-wrapper'), 'longPress') + expect(mockDrag).toHaveBeenCalled() + }) + + it('disables press when dragging', () => { + render( + , + ) + + fireEvent.press(screen.getByTestId('account-item-wrapper')) + expect(mockOnSelect).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx new file mode 100644 index 000000000..7961fe3a5 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx @@ -0,0 +1,79 @@ +import React, { useMemo } from 'react' +import { StyleSheet, TouchableOpacity } from 'react-native' +import { View } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { AccountCard } from '@/src/components/transactions-list/Card/AccountCard' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { Address } from '@/src/types/address' +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' + +interface AccountItemProps { + chains: Chain[] + account: SafeOverview + drag?: RenderItemParams['drag'] + isDragging?: boolean + activeAccount: Address + onSelect: (accountAddress: string) => void +} + +const getRightNodeLayout = (isEdit: boolean, isActive: boolean) => { + if (isEdit) { + return + } + + return isActive ? : null +} + +export function AccountItem({ account, drag, chains, isDragging, activeAccount, onSelect }: AccountItemProps) { + const { isEdit, onSafeDeleted } = useEditAccountItem() + const isActive = activeAccount === account.address.value + + const handleChainSelect = () => { + onSelect(account.address.value) + } + + const rightNode = useMemo(() => getRightNodeLayout(isEdit, isActive), [isEdit, isActive]) + + return ( + + + + + + ) + } + threshold={account.threshold} + owners={account.owners.length} + name={account.address.name || shortenAddress(account.address.value)} + address={account.address.value as Address} + balance={account.fiatTotal} + chains={isEdit ? undefined : chains} + rightNode={rightNode} + /> + + + ) +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + }, +}) + +export default AccountItem diff --git a/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts b/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts new file mode 100644 index 000000000..759ba32a0 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts @@ -0,0 +1,35 @@ +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/AccountItem/index.ts b/apps/mobile/src/features/Assets/components/AccountItem/index.ts new file mode 100644 index 000000000..1a0911a31 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AccountItem/index.ts @@ -0,0 +1,2 @@ +import { AccountItem } from './AccountItem' +export { AccountItem } diff --git a/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.container.tsx b/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.container.tsx new file mode 100644 index 000000000..5314a90f2 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.container.tsx @@ -0,0 +1,21 @@ +import usePendingTxs from '@/src/hooks/usePendingTxs' +import { router } from 'expo-router' +import { useCallback } from 'react' +import { AssetsHeader } from './AssetsHeader' + +export const AssetsHeaderContainer = () => { + const { amount, hasMore, isLoading } = usePendingTxs() + + const onPendingTransactionsPress = useCallback(() => { + router.push('/pending-transactions') + }, [router]) + + return ( + + ) +} diff --git a/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.tsx b/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.tsx new file mode 100644 index 000000000..fa0e16312 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { BalanceContainer } from '../Balance' +import { PendingTransactions } from '@/src/components/StatusBanners/PendingTransactions' +import { View } from 'tamagui' +import { StyledAssetsHeader } from './styles' + +interface AssetsHeaderProps { + amount: number + isLoading: boolean + onPendingTransactionsPress: () => void + hasMore: boolean +} + +export function AssetsHeader({ amount, isLoading, onPendingTransactionsPress, hasMore }: AssetsHeaderProps) { + return ( + + + {amount > 0 && ( + + )} + + + + + ) +} diff --git a/apps/mobile/src/features/Assets/components/AssetsHeader/index.tsx b/apps/mobile/src/features/Assets/components/AssetsHeader/index.tsx new file mode 100644 index 000000000..44bb8fad0 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AssetsHeader/index.tsx @@ -0,0 +1,2 @@ +import { AssetsHeaderContainer } from './AssetsHeader.container' +export { AssetsHeaderContainer } diff --git a/apps/mobile/src/features/Assets/components/AssetsHeader/styles.ts b/apps/mobile/src/features/Assets/components/AssetsHeader/styles.ts new file mode 100644 index 000000000..3f37a1100 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/AssetsHeader/styles.ts @@ -0,0 +1,5 @@ +import { styled, View } from 'tamagui' + +export const StyledAssetsHeader = styled(View, { + paddingHorizontal: 10, +}) diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx new file mode 100644 index 000000000..c84c3da24 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx @@ -0,0 +1,45 @@ +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 { Balance } from './Balance' +import { makeSafeId } from '@/src/utils/formatters' +import { RootState } from '@/src/store' +import { selectSafeInfo } from '@/src/store/safesSlice' +import { useAppSelector } from '@/src/store/hooks' + +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( + { + safes: chains.map((chain) => makeSafeId(chain.chainId, activeSafe.address)).join(','), + currency: 'usd', + trusted: true, + excludeSpam: true, + }, + { + pollingInterval: POLLING_INTERVAL, + skip: chains.length === 0, + }, + ) + + const handleChainChange = (chainId: string) => { + dispatch(switchActiveChain({ chainId })) + } + + return ( + + ) +} diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.tsx new file mode 100644 index 000000000..889665101 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Balance/Balance.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { Spinner, View } from 'tamagui' + +import { Alert } from '@/src/components/Alert' +import { Dropdown } 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' + +interface BalanceProps { + activeChainId: string + data: SafeOverview[] + isLoading: boolean + chains: Chain[] + onChainChange: (chainId: string) => 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)) + + return ( + + + {activeChainId && ( + + label={activeChain?.chainName} + dropdownTitle="Select network:" + leftNode={} + items={data} + keyExtractor={({ item }) => item.chainId} + renderItem={({ item, onClose }) => ( + { + onChainChange(chainId) + onClose() + }} + activeChain={activeChain} + fiatTotal={item.fiatTotal} + chains={chains} + chainId={item.chainId} + key={item.chainId} + /> + )} + /> + )} + + {isLoading ? ( + + ) : balance ? ( + + ) : ( + + )} + + + ) +} diff --git a/apps/mobile/src/features/Assets/components/Balance/ChainItems.tsx b/apps/mobile/src/features/Assets/components/Balance/ChainItems.tsx new file mode 100644 index 000000000..14af89b06 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Balance/ChainItems.tsx @@ -0,0 +1,40 @@ +import React from 'react' +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/SafeFontIcon' +import { TouchableOpacity } from 'react-native' + +interface ChainItemsProps { + activeChain: Chain + chains: Chain[] + chainId: string + fiatTotal: string + onSelect: (chainId: string) => void +} + +export function ChainItems({ chainId, chains, activeChain, fiatTotal, onSelect }: ChainItemsProps) { + const chain = chains.find((item) => item.chainId === chainId) + const isActive = chainId === activeChain.chainId + + const handleChainSelect = () => { + onSelect(chainId) + } + + if (!chain) { + return null + } + + return ( + + + } + /> + + + ) +} diff --git a/apps/mobile/src/features/Assets/components/Balance/index.tsx b/apps/mobile/src/features/Assets/components/Balance/index.tsx new file mode 100644 index 000000000..fd6e5f9ff --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Balance/index.tsx @@ -0,0 +1,2 @@ +import { BalanceContainer } from './Balance.container' +export { BalanceContainer } diff --git a/apps/mobile/src/features/Assets/components/Fallback/Fallback.tsx b/apps/mobile/src/features/Assets/components/Fallback/Fallback.tsx new file mode 100644 index 000000000..e290f8607 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Fallback/Fallback.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { Spinner } from 'tamagui' + +import { Alert } from '@/src/components/Alert' +import { SafeTab } from '@/src/components/SafeTab' +import { NoFunds } from '../NoFunds' + +export const Fallback = ({ loading, hasError }: { loading: boolean; hasError: boolean }) => ( + + {loading ? ( + + ) : hasError ? ( + + ) : ( + + )} + +) diff --git a/apps/mobile/src/features/Assets/components/Fallback/index.ts b/apps/mobile/src/features/Assets/components/Fallback/index.ts new file mode 100644 index 000000000..eb18c0665 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Fallback/index.ts @@ -0,0 +1,2 @@ +import { Fallback } from './Fallback' +export { Fallback } diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx new file mode 100644 index 000000000..04e77fb86 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { render, screen, fireEvent } from '@/src/tests/test-utils' +import { MyAccountsContainer } from './MyAccounts.container' +import { mockedChains } from '@/src/store/constants' +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' +import { GATEWAY_URL } from '@/src/config/constants' + +// Mock the safe item data +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'], +} + +// Create a constant object for the selector result +const mockActiveSafe = { address: '0x789' as `0x${string}`, chainId: '1' } +const mockChainIds = ['1'] as const + +// Mock Redux selectors +jest.mock('@/src/store/activeSafeSlice', () => ({ + selectActiveSafe: () => mockActiveSafe, + setActiveSafe: (payload: { address: `0x${string}`; chainId: string }) => ({ + type: 'activeSafe/setActiveSafe', + payload, + }), +})) + +jest.mock('@/src/store/chains', () => ({ + getChainsByIds: () => mockedChains, + selectAllChainsIds: () => mockChainIds, +})) + +jest.mock('@/src/store/myAccountsSlice', () => ({ + selectMyAccountsMode: () => false, +})) + +describe('MyAccountsContainer', () => { + const mockOnClose = jest.fn() + + beforeEach(() => { + server.use( + http.get(`${GATEWAY_URL}/v1/safes`, () => { + return HttpResponse.json([ + { + address: { value: '0x123', name: 'Test Safe' }, + chainId: '1', + threshold: 1, + owners: [{ value: '0x456' }], + fiatTotal: '1000', + queued: 0, + }, + ]) + }), + ) + }) + + afterEach(() => { + jest.clearAllMocks() + server.resetHandlers() + }) + + it('renders account item with correct data', () => { + render() + + expect(screen.getByText('Test Safe')).toBeTruthy() + expect(screen.getByText('1/1')).toBeTruthy() + expect(screen.getByText('$1000')).toBeTruthy() + }) + + it('calls onClose when account is selected', () => { + render() + + fireEvent.press(screen.getByTestId('account-item-wrapper')) + + expect(mockOnClose).toHaveBeenCalled() + }) + + it('renders with drag functionality when provided', () => { + const mockDrag = jest.fn() + + render() + + expect(screen.getByTestId('account-item-wrapper')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx new file mode 100644 index 000000000..ea034426b --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { RenderItemParams } from 'react-native-draggable-flatlist' +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 { getChainsByIds } from '@/src/store/chains' +import { RootState } from '@/src/store' +import { useMyAccountsService } from './hooks/useMyAccountsService' + +interface MyAccountsContainerProps { + item: SafesSliceItem + onClose: () => void + isDragging?: boolean + drag?: RenderItemParams['drag'] +} + +export function MyAccountsContainer({ item, isDragging, drag, onClose }: MyAccountsContainerProps) { + useMyAccountsService(item) + + const dispatch = useDispatch() + const activeSafe = useSelector(selectActiveSafe) + const filteredChains = useSelector((state: RootState) => getChainsByIds(state, item.chains)) + + const handleAccountSelected = () => { + const chainId = item.chains[0] + + dispatch( + setActiveSafe({ + address: item.SafeInfo.address.value as Address, + chainId, + }), + ) + + onClose() + } + + return ( + + ) +} diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.test.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.test.tsx new file mode 100644 index 000000000..76b422a0f --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.test.tsx @@ -0,0 +1,12 @@ +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(} />) + + 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 new file mode 100644 index 000000000..7d930ce39 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.tsx @@ -0,0 +1,58 @@ +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 ( + + + + + } + /> + + + Add Existing Account + + + + + + + } /> + + + Join New Account + + + + + + ) +} 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 new file mode 100644 index 000000000..165a90eb1 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.test.tsx @@ -0,0 +1,117 @@ +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 new file mode 100644 index 000000000..11cd7525c --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts @@ -0,0 +1,45 @@ +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({ + 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 new file mode 100644 index 000000000..00cc577a9 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts @@ -0,0 +1,39 @@ +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) => 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) => { + // 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/MyAccounts/index.ts b/apps/mobile/src/features/Assets/components/MyAccounts/index.ts new file mode 100644 index 000000000..e1d3d5f3b --- /dev/null +++ b/apps/mobile/src/features/Assets/components/MyAccounts/index.ts @@ -0,0 +1,2 @@ +export { MyAccountsFooter } from './MyAccountsFooter' +export { MyAccountsContainer } from './MyAccounts.container' diff --git a/apps/mobile/src/features/Assets/components/NFTs/NFTItem.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTItem.tsx new file mode 100644 index 000000000..bb36487f7 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NFTs/NFTItem.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { AssetsCard } from '@/src/components/transactions-list/Card/AssetsCard' +import { Collectible } from '@safe-global/store/gateway/AUTO_GENERATED/collectibles' + +export function NFTItem({ item }: { item: Collectible }) { + return ( + + ) +} 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 new file mode 100644 index 000000000..d3dedda2a --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { render, screen } from '@/src/tests/test-utils' +import { NFTsContainer } from './NFTs.container' +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' +import { GATEWAY_URL } from '@/src/config/constants' + +// Mock active safe selector with memoized object +const mockActiveSafe = { chainId: '1', address: '0x123' } + +jest.mock('@/src/store/activeSafeSlice', () => ({ + selectActiveSafe: () => mockActiveSafe, +})) + +describe('NFTsContainer', () => { + afterAll(() => { + server.resetHandlers() + }) + it('renders loading state initially', () => { + render() + expect(screen.getByTestId('fallback')).toBeTruthy() + }) + + it('renders error state when API fails', async () => { + server.use( + http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => { + return HttpResponse.error() + }), + ) + + render() + expect(await screen.findByTestId('fallback')).toBeTruthy() + }) + + it('renders NFT list when data is available', async () => { + render() + + // First verify we see the loading state + expect(screen.getByTestId('fallback')).toBeTruthy() + + // Then check for NFT content + const nft1 = await screen.findByText('NFT #1') + const nft2 = await screen.findByText('NFT #2') + + expect(nft1).toBeTruthy() + expect(nft2).toBeTruthy() + }) + + it('renders fallback when data is empty', async () => { + server.use( + http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => { + return HttpResponse.json({ results: [] }) + }), + ) + + render() + expect(await screen.findByTestId('fallback')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx new file mode 100644 index 000000000..10c88689a --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx @@ -0,0 +1,50 @@ +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, + useCollectiblesGetCollectiblesV2Query, +} from '@safe-global/store/gateway/AUTO_GENERATED/collectibles' + +import { Fallback } from '../Fallback' +import { NFTItem } from './NFTItem' +import { useInfiniteScroll } from '@/src/hooks/useInfiniteScroll' + +export function NFTsContainer() { + const activeSafe = useSelector(selectActiveSafe) + const [pageUrl, setPageUrl] = useState() + + const { data, isFetching, error, refetch } = useCollectiblesGetCollectiblesV2Query( + { + chainId: activeSafe.chainId, + safeAddress: activeSafe.address, + cursor: pageUrl && safelyDecodeURIComponent(pageUrl?.split('cursor=')[1]), + }, + { + pollingInterval: POLLING_INTERVAL, + }, + ) + const { list, onEndReached } = useInfiniteScroll({ + refetch, + setPageUrl, + data, + }) + + if (isFetching || !list?.length || error) { + return + } + + return ( + + onEndReached={onEndReached} + data={list} + renderItem={NFTItem} + keyExtractor={(item) => item.id} + /> + ) +} diff --git a/apps/mobile/src/features/Assets/components/NFTs/index.tsx b/apps/mobile/src/features/Assets/components/NFTs/index.tsx new file mode 100644 index 000000000..cbcb115f8 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NFTs/index.tsx @@ -0,0 +1,2 @@ +import { NFTsContainer } from './NFTs.container' +export { NFTsContainer } diff --git a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx new file mode 100644 index 000000000..0c4aee660 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx @@ -0,0 +1,95 @@ +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 { 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 { selectAppNotificationStatus } from '@/src/store/notificationsSlice' + +const dropdownLabelProps = { + fontSize: '$5', + fontWeight: 600, +} as const + +export const Navbar = () => { + const dispatch = useAppDispatch() + const isEdit = useAppSelector(selectMyAccountsMode) + const activeSafe = useAppSelector(selectActiveSafe) + const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus) + const { safes, onDragEnd } = useMyAccountsSortable() + + const handleNotificationAccess = () => { + if (!isAppNotificationEnabled) { + router.navigate('/notifications-opt-in') + } + // TODO: navigate to notifications list when notifications are enabled + } + + const toggleEditMode = () => { + dispatch(toggleMode()) + } + + return ( + + + + + label={shortenAddress(activeSafe.address)} + labelProps={dropdownLabelProps} + dropdownTitle="My accounts" + leftNode={} + items={safes} + keyExtractor={({ item }) => item.SafeInfo.address.value} + footerComponent={MyAccountsFooter} + renderItem={MyAccountsContainer} + sortable={isEdit} + onDragEnd={onDragEnd} + actions={ + safes.length > 1 && ( + +
{isEdit ? 'Done' : 'Edit'}
+
+ ) + } + /> + + + + + + + + +
+
+
+ ) +} + +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/index.tsx b/apps/mobile/src/features/Assets/components/Navbar/index.tsx new file mode 100644 index 000000000..7d0badea6 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Navbar/index.tsx @@ -0,0 +1 @@ +export { Navbar } from './Navbar' diff --git a/apps/mobile/src/features/Assets/components/NoFunds/EmptyToken.tsx b/apps/mobile/src/features/Assets/components/NoFunds/EmptyToken.tsx new file mode 100644 index 000000000..5b49887b0 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NoFunds/EmptyToken.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import Svg, { Path } from 'react-native-svg' + +function EmptyToken() { + return ( + + + + + + + + + + + + + + ) +} + +export default EmptyToken diff --git a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx new file mode 100644 index 000000000..628372895 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { render, screen } from '@/src/tests/test-utils' +import { NoFunds } from './NoFunds' + +describe('NoFunds', () => { + it('renders the empty token component', () => { + render() + + // Check for the main elements + expect(screen.getByText('Add funds to get started')).toBeTruthy() + expect( + screen.getByText('Send funds to your Safe Account from another wallet by copying your address.'), + ).toBeTruthy() + }) + + it('renders the EmptyToken component', () => { + render() + + // Check if EmptyToken is rendered by looking for its container + expect(screen.getByTestId('empty-token')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx new file mode 100644 index 000000000..1c5baad6e --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { H3, Text, View } from 'tamagui' +import EmptyToken from './EmptyToken' + +export function NoFunds() { + return ( + + +

Add funds to get started

+ + Send funds to your Safe Account from another wallet by copying your address. + +
+ ) +} diff --git a/apps/mobile/src/features/Assets/components/NoFunds/index.ts b/apps/mobile/src/features/Assets/components/NoFunds/index.ts new file mode 100644 index 000000000..cc15cc4e9 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NoFunds/index.ts @@ -0,0 +1,2 @@ +import { NoFunds } from './NoFunds' +export { NoFunds } 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 new file mode 100644 index 000000000..d2892830c --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { render, screen } from '@/src/tests/test-utils' +import { TokensContainer } from './Tokens.container' +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' +import { GATEWAY_URL } from '@/src/config/constants' + +// Mock active safe selector with memoized object +const mockActiveSafe = { chainId: '1', address: '0x123' } + +jest.mock('@/src/store/activeSafeSlice', () => ({ + selectActiveSafe: () => mockActiveSafe, +})) + +describe('TokensContainer', () => { + afterEach(() => { + server.resetHandlers() + }) + + afterAll(() => { + server.close() + }) + + it('renders loading state initially', () => { + render() + expect(screen.getByTestId('fallback')).toBeTruthy() + }) + + it('renders error state when API fails', async () => { + server.use( + http.get(`${GATEWAY_URL}/api/v1/chains/:chainId/safes/:safeAddress/balances/usd`, () => { + return HttpResponse.error() + }), + ) + + render() + + expect(await screen.findByTestId('fallback')).toBeTruthy() + }) + + it('renders token list when data is available', async () => { + // Setup response spy + render() + + // First verify we see the loading state + expect(screen.getByTestId('fallback')).toBeTruthy() + + // Then check for content + const ethText = await screen.findByText('Ethereum') + const ethAmount = await screen.findByText('1 ETH') + const ethValue = await screen.findByText('$2000') + + expect(ethText).toBeTruthy() + expect(ethAmount).toBeTruthy() + expect(ethValue).toBeTruthy() + }) + + it('renders fallback when data is empty', async () => { + server.use( + http.get(`${GATEWAY_URL}/api/v1/chains/:chainId/safes/:safeAddress/balances/usd`, () => { + return HttpResponse.json({ items: [] }) + }), + ) + + render() + + expect(await screen.findByTestId('fallback')).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 new file mode 100644 index 000000000..2f3a671f3 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { ListRenderItem } from 'react-native' +import { useSelector } from 'react-redux' +import { 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' + +export function TokensContainer() { + const activeSafe = useSelector(selectActiveSafe) + + const { data, isFetching, error } = useBalancesGetBalancesV1Query( + { + chainId: activeSafe.chainId, + fiatCode: 'USD', + safeAddress: activeSafe.address, + excludeSpam: false, + trusted: true, + }, + { + pollingInterval: POLLING_INTERVAL, + }, + ) + + const renderItem: ListRenderItem = React.useCallback(({ item }) => { + return ( + + ${item.fiatBalance} + + } + /> + ) + }, []) + + if (isFetching || !data?.items.length || error) { + return + } + + return ( + + data={data?.items} + renderItem={renderItem} + keyExtractor={(item, index): string => item.tokenInfo.name + index} + /> + ) +} diff --git a/apps/mobile/src/features/Assets/components/Tokens/index.tsx b/apps/mobile/src/features/Assets/components/Tokens/index.tsx new file mode 100644 index 000000000..c2dd3ac69 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Tokens/index.tsx @@ -0,0 +1,2 @@ +import { TokensContainer } from './Tokens.container' +export { TokensContainer } diff --git a/apps/mobile/src/features/Assets/index.tsx b/apps/mobile/src/features/Assets/index.tsx new file mode 100644 index 000000000..d08f5e70c --- /dev/null +++ b/apps/mobile/src/features/Assets/index.tsx @@ -0,0 +1,2 @@ +import { AssetsContainer } from './Assets.container' +export { AssetsContainer } diff --git a/apps/mobile/src/features/Assets/styles.ts b/apps/mobile/src/features/Assets/styles.ts new file mode 100644 index 000000000..3f37a1100 --- /dev/null +++ b/apps/mobile/src/features/Assets/styles.ts @@ -0,0 +1,5 @@ +import { styled, View } from 'tamagui' + +export const StyledAssetsHeader = styled(View, { + paddingHorizontal: 10, +}) diff --git a/apps/mobile/src/features/Notifications/Notifications.container.test.tsx b/apps/mobile/src/features/Notifications/Notifications.container.test.tsx new file mode 100644 index 000000000..294023923 --- /dev/null +++ b/apps/mobile/src/features/Notifications/Notifications.container.test.tsx @@ -0,0 +1,37 @@ +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() + expect(getAllByText('Allow notifications')).toHaveLength(1) + }) + + it('triggers notification action on switch change', async () => { + const { getByTestId } = render() + 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 new file mode 100644 index 000000000..2a794f90f --- /dev/null +++ b/apps/mobile/src/features/Notifications/Notifications.container.tsx @@ -0,0 +1,38 @@ +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 ( + + + Notifications + + + Stay up-to-date and get notified about activities in your account, based on your needs. + + + } + /> + + ) +} diff --git a/apps/mobile/src/features/Notifications/index.tsx b/apps/mobile/src/features/Notifications/index.tsx new file mode 100644 index 000000000..b5f074338 --- /dev/null +++ b/apps/mobile/src/features/Notifications/index.tsx @@ -0,0 +1,2 @@ +import { NotificationsContainer } from './Notifications.container' +export { NotificationsContainer } diff --git a/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx b/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx new file mode 100644 index 000000000..0c4fe0367 --- /dev/null +++ b/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { Onboarding } from './Onboarding.container' +import { fireEvent, render } from '@/src/tests/test-utils' + +const mockNavigate = jest.fn() + +jest.mock('expo-router', () => ({ + useRouter: () => ({ + navigate: mockNavigate, + }), +})) + +describe('Onboarding Component', () => { + it('renders correctly', () => { + const { getAllByText } = render() + expect(getAllByText('Get started')).toHaveLength(1) + }) + + it('navigates on button press', () => { + const { getByText } = render() + const button = getByText('Get started') + + fireEvent.press(button) + expect(mockNavigate).toHaveBeenCalledWith('/(tabs)') + }) +}) diff --git a/apps/mobile/src/features/Onboarding/Onboarding.container.tsx b/apps/mobile/src/features/Onboarding/Onboarding.container.tsx new file mode 100644 index 000000000..95b9c6f63 --- /dev/null +++ b/apps/mobile/src/features/Onboarding/Onboarding.container.tsx @@ -0,0 +1,7 @@ +import React from 'react' +import { OnboardingCarousel } from './components/OnboardingCarousel' +import { items } from './components/OnboardingCarousel/items' + +export function Onboarding() { + return +} diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.test.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.test.tsx new file mode 100644 index 000000000..b96f4c969 --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.test.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { CarouselFeedback } from './CarouselFeedback' +import { render } from '@/src/tests/test-utils' +import darkPalette from '@/src/theme/palettes/darkPalette' + +describe('CarouselFeedback', () => { + it('renders with active state', () => { + const { getByTestId } = render() + + const carouselFeedback = getByTestId('carousel-feedback') + + expect(carouselFeedback.props.style.backgroundColor).toBe(darkPalette.background.default) + }) + + it('renders with inactive state', () => { + const { getByTestId } = render() + const carouselFeedback = getByTestId('carousel-feedback') + + expect(carouselFeedback.props.style.backgroundColor).toBe(darkPalette.primary.light) + }) +}) diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.tsx new file mode 100644 index 000000000..b3f4491df --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.tsx @@ -0,0 +1,35 @@ +import React, { useEffect } from 'react' +import Animated, { useSharedValue, withSpring } from 'react-native-reanimated' +import { useTheme } from 'tamagui' + +interface CarouselFeedbackProps { + isActive: boolean +} + +const UNACTIVE_WIDTH = 4 +const ACTIVE_WIDTH = 14 + +export function CarouselFeedback({ isActive }: CarouselFeedbackProps) { + const width = useSharedValue(UNACTIVE_WIDTH) + const theme = useTheme() + + useEffect(() => { + if (isActive) { + width.value = withSpring(ACTIVE_WIDTH) + } else { + width.value = withSpring(UNACTIVE_WIDTH) + } + }, [isActive]) + + return ( + + ) +} diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.test.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.test.tsx new file mode 100644 index 000000000..16c2db7ce --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.test.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { CarouselItem } from './CarouselItem' // adjust the import path as necessary +import { Text } from 'tamagui' +import { render } from '@/src/tests/test-utils' + +describe('CarouselItem Component', () => { + it('renders correctly with all props', () => { + const item = { + title: Test Title, + description: 'Test Description', + image: Test Image, + name: 'nevinha', + } + + const { getByText } = render() + + expect(getByText('Test Title')).toBeTruthy() + expect(getByText('Test Description')).toBeTruthy() + expect(getByText('Test Image')).toBeTruthy() + }) + + it('renders correctly without optional props', () => { + const item = { + title: Test Title, + name: 'Test Name', + } + + const { getByText, queryByText } = render() + + expect(getByText('Test Title')).toBeTruthy() + expect(queryByText('Test Description')).toBeNull() // Description is optional and not provided + }) +}) diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.tsx new file mode 100644 index 000000000..bc596a35c --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.tsx @@ -0,0 +1,29 @@ +import { Text, View, YStack } from 'tamagui' + +export type CarouselItem = { + title: string | React.ReactNode + name: string + description?: string + image?: React.ReactNode + imagePosition?: 'top' | 'bottom' +} + +interface CarouselItemProps { + item: CarouselItem +} + +export const CarouselItem = ({ item: { title, description, image, imagePosition = 'top' } }: CarouselItemProps) => { + return ( + + {imagePosition === 'top' && image} + + {title} + + + {description} + + + {imagePosition === 'bottom' && image} + + ) +} diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.stories.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.stories.tsx new file mode 100644 index 000000000..2bcc5428d --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react' +import React from 'react' +import { OnboardingCarousel } from './OnboardingCarousel' +import { items } from './items' + +const meta: Meta = { + title: 'Carousel', + component: OnboardingCarousel, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: function Render(args) { + return + }, +} diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.test.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.test.tsx new file mode 100644 index 000000000..d12d74f6a --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.test.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { OnboardingCarousel } from './OnboardingCarousel' +import { Text } from 'tamagui' +import { render } from '@/src/tests/test-utils' + +describe('OnboardingCarousel', () => { + const items = [ + { name: 'Item1', title: Item1 Title }, + { name: 'Item2', title: Item2 Title }, + { name: 'Item3', title: Item3 Title }, + ] + + // react-native-collapsible-tab-view does not returns any information about the tabs children + // that is why we only test the children component here =/ + it('renders without crashing', () => { + const { getByTestId } = render() + + expect(getByTestId('carrousel')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx new file mode 100644 index 000000000..df34dae26 --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react' +import { CarouselItem } from './CarouselItem' +import { 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' +interface OnboardingCarouselProps { + items: CarouselItem[] +} + +export function OnboardingCarousel({ items }: OnboardingCarouselProps) { + const [activeTab, setActiveTab] = useState(items[0].name) + const router = useRouter() + + const onGetStartedPress = () => { + router.navigate('/(tabs)') + } + + return ( + + setActiveTab(event.tabName)} + initialTabName={items[0].name} + renderTabBar={() => <>} + > + {items.map((item, index) => ( + + + + ))} + + + + + {items.map((item) => ( + + ))} + + + + + + + ) +} diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/index.ts b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/index.ts new file mode 100644 index 000000000..cac5baa9a --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/index.ts @@ -0,0 +1,2 @@ +import { OnboardingCarousel } from './OnboardingCarousel' +export { OnboardingCarousel } diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/items.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/items.tsx new file mode 100644 index 000000000..55fc6d3ec --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/items.tsx @@ -0,0 +1,105 @@ +import { Dimensions, StyleSheet } from 'react-native' +import { H1, Image, View } from 'tamagui' +import Signing from '@/assets/images/illustration.png' + +import TrackAnywhere from '@/assets/images/anywhere.png' +import { CarouselItem } from './CarouselItem' +import { ParticlesLogo } from '../ParticlesLogo' + +const windowHeight = Dimensions.get('window').height + +const styles = StyleSheet.create({ + image: { + width: '100%', + }, + anywhere: { + height: Math.abs(windowHeight * 0.32), + }, + signing: { + height: Math.abs(windowHeight * 0.3), + }, + notifications: { + height: Math.abs(windowHeight * 0.32), + }, + textContainer: { + textAlign: 'center', + flexDirection: 'column', + }, +}) + +export const items: CarouselItem[] = [ + { + name: 'multisig', + image: ( + + + + ), + title: ( + <> +

+ Your main +

+

+

+ Safe +

{' '} + multisig + +

+ companion +

+ + ), + }, + { + name: 'tracking', + image: , + title: ( + <> +

+ Track +

+

+ everything. +

+

+ Anywhere. +

+ + ), + description: 'Quickly check your asset balances and portfolio performance anytime, anywhere.', + }, + { + name: 'signing', + image: , + title: ( + <> +

+ Seamless +

+

+ signing +

+ + ), + description: + 'Sign and execute transactions securely from your mobile device. Ensuring your assets are protected, even on the move.', + }, + { + name: 'update-to-date', + image: , + title: ( + <> +

+ Stay +

+

+ up-to-date +

+ + ), + description: + 'Sign and execute transactions securely from your mobile device. Ensuring your assets are protected, even on the move.', + }, +] diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.test.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.test.tsx new file mode 100644 index 000000000..99232471d --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.test.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { OnboardingHeader } from './OnboardingHeader' +import { render } from '@testing-library/react-native' + +test('renders the OnboardingHeader component with the Safe Wallet image', () => { + const { getByLabelText } = render() + + const image = getByLabelText(/Safe Wallet/i) + expect(image).toBeTruthy() +}) diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.tsx new file mode 100644 index 000000000..3a9b18e45 --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.tsx @@ -0,0 +1,16 @@ +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', +}) + +export function OnboardingHeader() { + return ( + + + + ) +} diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingHeader/index.ts b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/index.ts new file mode 100644 index 000000000..ea4b33e18 --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/index.ts @@ -0,0 +1,2 @@ +import { OnboardingHeader } from './OnboardingHeader' +export { OnboardingHeader } diff --git a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.test.tsx b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.test.tsx new file mode 100644 index 000000000..861b813bb --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.test.tsx @@ -0,0 +1,10 @@ +import { render } from '@/src/tests/test-utils' +import { ParticlesLogo } from './ParticlesLogo' + +describe('ParticlesLogo', () => { + it('should render default markup', () => { + const container = render() + + 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 new file mode 100644 index 000000000..b344654ab --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import Svg, { Path } from 'react-native-svg' + +export function ParticlesLogo() { + return ( + + + + + + + + + + + + ) +} 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 new file mode 100644 index 000000000..fb8cbe452 --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/__snapshots__/ParticlesLogo.test.tsx.snap @@ -0,0 +1,169 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ParticlesLogo should render default markup 1`] = ` + + + + + + + + + + + + + + + +`; diff --git a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/index.ts b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/index.ts new file mode 100644 index 000000000..41f826507 --- /dev/null +++ b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/index.ts @@ -0,0 +1,2 @@ +import { ParticlesLogo } from './ParticlesLogo' +export { ParticlesLogo } diff --git a/apps/mobile/src/features/Onboarding/index.ts b/apps/mobile/src/features/Onboarding/index.ts new file mode 100644 index 000000000..68f4aa89d --- /dev/null +++ b/apps/mobile/src/features/Onboarding/index.ts @@ -0,0 +1,2 @@ +import { Onboarding } from './Onboarding.container' +export { Onboarding } diff --git a/apps/mobile/src/features/PendingTx/PendingTx.container.tsx b/apps/mobile/src/features/PendingTx/PendingTx.container.tsx new file mode 100644 index 000000000..da1118db1 --- /dev/null +++ b/apps/mobile/src/features/PendingTx/PendingTx.container.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { PendingTxListContainer } from '@/src/features/PendingTx/components/PendingTxList' +import usePendingTxs from '@/src/hooks/usePendingTxs' + +export function PendingTxContainer() { + const { data, isLoading, fetchMoreTx, hasMore, amount } = usePendingTxs() + + return ( + + ) +} diff --git a/apps/mobile/src/features/PendingTx/components/PendingTxList/PendingTxList.container.tsx b/apps/mobile/src/features/PendingTx/components/PendingTxList/PendingTxList.container.tsx new file mode 100644 index 000000000..20b682879 --- /dev/null +++ b/apps/mobile/src/features/PendingTx/components/PendingTxList/PendingTxList.container.tsx @@ -0,0 +1,68 @@ +import { SafeListItem } from '@/src/components/SafeListItem' +import React from 'react' +import { SectionList } from 'react-native' +import { Spinner, View } from 'tamagui' +import { Badge } from '@/src/components/Badge' +import { NavBarTitle } from '@/src/components/Title/NavBarTitle' +import { LargeHeaderTitle } from '@/src/components/Title/LargeHeaderTitle' +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' + +export interface GroupedPendingTxsWithTitle { + title: string + data: (PendingTransactionItems | TransactionQueuedItem[])[] +} + +interface PendingTxListContainerProps { + transactions: GroupedPendingTxsWithTitle[] + onEndReached: (info: { distanceFromEnd: number }) => void + isLoading?: boolean + amount: number + hasMore: boolean +} + +export function PendingTxListContainer({ + transactions, + onEndReached, + isLoading, + hasMore, + amount, +}: PendingTxListContainerProps) { + const { handleScroll } = useScrollableHeader({ + children: ( + <> + Pending Transactions + + + ), + }) + + const LargeHeader = ( + + Pending Transactions + {isLoading ? ( + + ) : ( + + )} + + ) + + return ( + : undefined} + renderSectionHeader={({ section: { title } }) => } + onScroll={handleScroll} + scrollEventThrottle={16} + /> + ) +} diff --git a/apps/mobile/src/features/PendingTx/components/PendingTxList/index.ts b/apps/mobile/src/features/PendingTx/components/PendingTxList/index.ts new file mode 100644 index 000000000..b3867e5ad --- /dev/null +++ b/apps/mobile/src/features/PendingTx/components/PendingTxList/index.ts @@ -0,0 +1,2 @@ +import { PendingTxListContainer } from './PendingTxList.container' +export { PendingTxListContainer } diff --git a/apps/mobile/src/features/PendingTx/index.tsx b/apps/mobile/src/features/PendingTx/index.tsx new file mode 100644 index 000000000..74ebac85f --- /dev/null +++ b/apps/mobile/src/features/PendingTx/index.tsx @@ -0,0 +1,2 @@ +import { PendingTxContainer } from './PendingTx.container' +export { PendingTxContainer } diff --git a/apps/mobile/src/features/PendingTx/utils.tsx b/apps/mobile/src/features/PendingTx/utils.tsx new file mode 100644 index 000000000..0b77d51f5 --- /dev/null +++ b/apps/mobile/src/features/PendingTx/utils.tsx @@ -0,0 +1,134 @@ +import { TransactionQueuedItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { + getBulkGroupTxHash, + getTxHash, + isConflictHeaderListItem, + isLabelListItem, + isTransactionListItem, +} from '@/src/utils/transaction-guards' +import { groupBulkTxs } from '@/src/utils/transactions' +import { type PendingTransactionItems, TransactionListItemType } from '@safe-global/store//src/gateway/types' +import { View } from 'tamagui' +import { TxGroupedCard } from '@/src/components/transactions-list/Card/TxGroupedCard' +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 { GroupedPendingTxsWithTitle } from './components/PendingTxList/PendingTxList.container' + +type GroupedTxs = (PendingTransactionItems | TransactionQueuedItem[])[] + +export const groupTxs = (list: PendingTransactionItems[]) => { + const groupedByConflicts = groupConflictingTxs(list) + return groupBulkTxs(groupedByConflicts) +} + +export const groupPendingTxs = (list: PendingTransactionItems[]) => { + const transactions = groupTxs(list) + const sections = ['Next', 'Queued'] + + const txSections: { + pointer: number + amount: number + sections: GroupedPendingTxsWithTitle[] + } = { + pointer: -1, + amount: 0, + sections: [ + { title: 'Ready to execute', data: [] }, + { title: 'Confirmation needed', data: [] }, + ], + } + + return transactions.reduce((acc, item) => { + if ('type' in item && isLabelListItem(item)) { + acc.pointer = sections.indexOf(item.label) + } else if ( + acc.sections[acc.pointer] && + (Array.isArray(item) || item.type === TransactionListItemType.TRANSACTION) + ) { + acc.amount += Array.isArray(item) ? item.length : 1 + + acc.sections[acc.pointer].data.push(item as TransactionQueuedItem) + } + + return acc + }, txSections) +} + +export const groupConflictingTxs = (list: PendingTransactionItems[]): GroupedTxs => + list + .reduce((resultItems, item) => { + if (isConflictHeaderListItem(item)) { + return [...resultItems, []] + } + + const prevItem = resultItems[resultItems.length - 1] + if (Array.isArray(prevItem) && isTransactionListItem(item) && item.conflictType !== 'None') { + const updatedPrevItem = [...prevItem, item] + return [...resultItems.slice(0, -1), updatedPrevItem] + } + + return [...resultItems, item] + }, []) + .map((item) => { + return Array.isArray(item) + ? item.sort((a, b) => { + return b.transaction.timestamp - a.transaction.timestamp + }) + : item + }) + +export const renderItem = ({ + item, + index, +}: { + item: PendingTransactionItems | TransactionQueuedItem[] + index: number +}) => { + if (Array.isArray(item)) { + // Handle bulk transactions + return ( + + {getBulkGroupTxHash(item) ? ( + + ) : ( + + )} + + ) + } + + if (isLabelListItem(item)) { + return ( + + + + ) + } + + if (isTransactionListItem(item)) { + return ( + + + + ) + } + + return null +} + +export const keyExtractor = (item: PendingTransactionItems | TransactionQueuedItem[], index: number) => { + if (Array.isArray(item)) { + const txGroupHash = getBulkGroupTxHash(item) + if (txGroupHash) { + return txGroupHash + index + } + + if (isTransactionListItem(item[0])) { + return getTxHash(item[0]) + index + } + return String(index) + } + return String(index) +} diff --git a/apps/mobile/src/features/Settings/Settings.container.tsx b/apps/mobile/src/features/Settings/Settings.container.tsx new file mode 100644 index 000000000..f5eb3930d --- /dev/null +++ b/apps/mobile/src/features/Settings/Settings.container.tsx @@ -0,0 +1,15 @@ +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' + +export const SettingsContainer = () => { + const { chainId, address } = useSelector(selectActiveSafe) + const { data = {} as SafeState } = useGetSafeQuery({ + chainId: chainId, + safeAddress: address, + }) + + return +} diff --git a/apps/mobile/src/features/Settings/Settings.tsx b/apps/mobile/src/features/Settings/Settings.tsx new file mode 100644 index 000000000..44f3195ed --- /dev/null +++ b/apps/mobile/src/features/Settings/Settings.tsx @@ -0,0 +1,159 @@ +import React from 'react' +import { H2, ScrollView, Text, View, XStack, YStack } from 'tamagui' +import { SafeFontIcon as Icon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { SafeListItem } from '@/src/components/SafeListItem' +import { Skeleton } from 'moti/skeleton' +import { Pressable } 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' +import { router } from 'expo-router' +import { IdenticonWithBadge } from '@/src/features/Settings/components/IdenticonWithBadge' + +import { Navbar } from '@/src/features/Settings/components/Navbar/Navbar' + +interface SettingsProps { + data: SafeState + address: `0x${string}` +} + +export const Settings = ({ address, data }: SettingsProps) => { + const { owners = [], threshold, implementation } = data + + return ( + <> + + + + + + +

+ My DAO +

+ + + + + + + saaafe.xyz + + +
+ + + + + + + {owners.length} + + + + + Signers + + + + + + + + {threshold}/{owners.length} + + + + + Threshold + + + + + + + Members + [{ opacity: pressed ? 0.5 : 1.0 }]} + onPress={() => { + router.push('/signers') + }} + > + } + rightNode={ + + + + {owners.length} + + + + + + + } + /> + + + + + General + + [{ opacity: pressed ? 0.5 : 1.0 }]} + onPress={() => { + router.push('/notifications') + }} + > + } + rightNode={} + /> + + + + +
+ + {/* Footer */} + + {implementation?.name} + +
+
+ + ) +} diff --git a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx new file mode 100644 index 000000000..c0eec6e4d --- /dev/null +++ b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx @@ -0,0 +1,21 @@ +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 { AppSettings } from './AppSettings' + +export const AppSettingsContainer = () => { + const dispatch = useDispatch() + const activeSafe = useSelector(selectActiveSafe) + const [safeAddress, setSafeAddress] = useState('') + + const handleSubmit = () => { + dispatch( + setActiveSafe({ + chainId: activeSafe.chainId, + address: safeAddress as Address, + }), + ) + } + return +} diff --git a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.tsx b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.tsx new file mode 100644 index 000000000..bfc6dc0c7 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.tsx @@ -0,0 +1,29 @@ +import { View, Text, Input } from 'tamagui' +import { TouchableOpacity } from 'react-native' + +interface AppSettingsProps { + address: string + onSubmit: () => void + onAddressChange: (address: string) => void +} + +export const AppSettings = ({ address, onAddressChange, onSubmit }: AppSettingsProps) => { + return ( + + + + + Set Safe Address + + + + ) +} diff --git a/apps/mobile/src/features/Settings/components/AppSettings/index.ts b/apps/mobile/src/features/Settings/components/AppSettings/index.ts new file mode 100644 index 000000000..5e68ca415 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/AppSettings/index.ts @@ -0,0 +1,2 @@ +import { AppSettingsContainer } from './AppSettings.container' +export { AppSettingsContainer } diff --git a/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx new file mode 100644 index 000000000..2a1de35df --- /dev/null +++ b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx @@ -0,0 +1,55 @@ +import { View } from 'tamagui' +import { Identicon } from '@/src/components/Identicon' +import { Skeleton } from 'moti/skeleton' +import { Badge } from '@/src/components/Badge' +import React from 'react' +import { StyleSheet } from 'react-native' +import { Address } from '@/src/types/address' + +type IdenticonWithBadgeProps = { + address: Address + badgeContent?: string + size?: number + testID?: string + fontSize?: number +} + +export const IdenticonWithBadge = ({ + address, + testID, + badgeContent, + fontSize = 12, + size = 56, +}: IdenticonWithBadgeProps) => { + return ( + + + + + {badgeContent && ( + + )} + + + + ) +} + +const styles = StyleSheet.create({ + container: { + position: 'relative', + }, + badge: { + position: 'absolute', + top: -5, + right: -10, + }, +}) diff --git a/apps/mobile/src/features/Settings/components/IdenticonWithBadge/index.ts b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/index.ts new file mode 100644 index 000000000..709c911ee --- /dev/null +++ b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/index.ts @@ -0,0 +1,2 @@ +import { IdenticonWithBadge } from './IdenticonWithBadge' +export { IdenticonWithBadge } diff --git a/apps/mobile/src/features/Settings/components/Navbar/Navbar.tsx b/apps/mobile/src/features/Settings/components/Navbar/Navbar.tsx new file mode 100644 index 000000000..1aa862e81 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Navbar/Navbar.tsx @@ -0,0 +1,36 @@ +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 ( + + + + + + + + + + + ) +} + +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/SettingsButton.tsx b/apps/mobile/src/features/Settings/components/Navbar/SettingsButton.tsx new file mode 100644 index 000000000..927a388d7 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Navbar/SettingsButton.tsx @@ -0,0 +1,21 @@ +import { Button } from 'tamagui' +import { router } from 'expo-router' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import React from 'react' + +export const SettingsButton = () => { + return ( + + ) +} diff --git a/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx new file mode 100644 index 000000000..db7e12d20 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx @@ -0,0 +1,147 @@ +import { Button, useTheme } from 'tamagui' +import { MenuView, NativeActionEvent } from '@react-native-menu/menu' +import { Linking, Platform } from 'react-native' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import React from 'react' +import { getExplorerLink } from '@/src/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' + +type Props = { + safeAddress: string | undefined +} +export const SettingsMenu = ({ safeAddress }: Props) => { + const toast = useToastController() + const activeSafe = useAppSelector(selectActiveSafe) + 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 ( + { + console.warn(JSON.stringify(nativeEvent)) + + if (nativeEvent.event === 'rename') { + console.log('rename') + toBeImplemented() + } + + if (nativeEvent.event === 'explorer') { + const link = getExplorerLink(safeAddress, activeChain.blockExplorerUriTemplate) + Linking.openURL(link.href) + } + + if (nativeEvent.event === 'copy') { + console.log('copy') + copyAndDispatchToast(safeAddress) + } + + if (nativeEvent.event === 'remove') { + console.log('remove') + toBeImplemented() + } + + if (nativeEvent.event === 'share') { + console.log('share') + toBeImplemented() + } + }} + color={color} + destructiveColor={colorError} + /> + ) +} + +type MenuProps = { + onPressAction: (event: NativeActionEvent) => void + color: string + destructiveColor: string +} +const Menu = ({ onPressAction, color, destructiveColor }: MenuProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/features/Settings/components/Navbar/index.ts b/apps/mobile/src/features/Settings/components/Navbar/index.ts new file mode 100644 index 000000000..a87e58336 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Navbar/index.ts @@ -0,0 +1,2 @@ +import { Navbar } from './Navbar' +export { Navbar } diff --git a/apps/mobile/src/features/Settings/index.tsx b/apps/mobile/src/features/Settings/index.tsx new file mode 100644 index 000000000..1d77d701b --- /dev/null +++ b/apps/mobile/src/features/Settings/index.tsx @@ -0,0 +1,2 @@ +import { SettingsContainer } from './Settings.container' +export { SettingsContainer } diff --git a/apps/mobile/src/features/Signers/Signers.container.tsx b/apps/mobile/src/features/Signers/Signers.container.tsx new file mode 100644 index 000000000..aea2b4335 --- /dev/null +++ b/apps/mobile/src/features/Signers/Signers.container.tsx @@ -0,0 +1,42 @@ +import { View } from 'tamagui' +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' + +export const SignersContainer = () => { + const { group, isFetching } = useSignersGroupService() + const router = useRouter() + + const onImportSigner = () => { + router.push('/import-signers') + } + + const signersSections = useMemo(() => { + if (!group.imported) { + return [] + } + + return group.imported?.data.length ? Object.values(group) : [group.notImported] + }, [group]) + + return ( + + + + + + + + + + ) +} diff --git a/apps/mobile/src/features/Signers/components/SignersList/SignersList.tsx b/apps/mobile/src/features/Signers/components/SignersList/SignersList.tsx new file mode 100644 index 000000000..719d4f62e --- /dev/null +++ b/apps/mobile/src/features/Signers/components/SignersList/SignersList.tsx @@ -0,0 +1,64 @@ +import React, { useMemo } from 'react' + +import { SafeListItem } from '@/src/components/SafeListItem' +import { getTokenValue, Spinner } from 'tamagui' + +import { SectionList } from 'react-native' +import { useCallback } from 'react' +import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' +import { NavBarTitle } from '@/src/components/Title' +import { SignersListHeader } from './SignersListHeader' +import { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import SignersListItem from './SignersListItem' + +export type SignerSection = { + title: string + data: SafeState['owners'] +} + +const keyExtractor = (item: AddressInfo, index: number) => item.value + index + +interface SignersListProps { + signersGroup: SignerSection[] + isFetching: boolean + hasLocalSingers: boolean +} + +export function SignersList({ signersGroup, isFetching, hasLocalSingers }: SignersListProps) { + const { handleScroll } = useScrollableHeader({ + children: Signers, + }) + + const renderItem = useCallback( + ({ item, index }: { item: AddressInfo; index: number }) => { + return + }, + [signersGroup], + ) + + const ListHeaderComponent = useCallback(() => , [hasLocalSingers]) + const contentContainerStyle = useMemo( + () => ({ + paddingHorizontal: getTokenValue('$3'), + }), + [], + ) + + return ( + + testID="tx-history-list" + onScroll={handleScroll} + ListHeaderComponent={ListHeaderComponent} + stickySectionHeadersEnabled + contentInsetAdjustmentBehavior="automatic" + sections={signersGroup} + ListFooterComponent={isFetching ? : undefined} + keyExtractor={keyExtractor} + renderItem={renderItem} + scrollEventThrottle={16} + contentContainerStyle={contentContainerStyle} + renderSectionHeader={({ section: { title } }) => } + /> + ) +} diff --git a/apps/mobile/src/features/Signers/components/SignersList/SignersListHeader.tsx b/apps/mobile/src/features/Signers/components/SignersList/SignersListHeader.tsx new file mode 100644 index 000000000..7b535df09 --- /dev/null +++ b/apps/mobile/src/features/Signers/components/SignersList/SignersListHeader.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { View } from 'tamagui' +import { Alert } from '@/src/components/Alert' +import { SectionTitle } from '@/src/components/Title' + +interface SignersListHeaderProps { + withAlert: boolean +} + +export function SignersListHeader({ withAlert }: SignersListHeaderProps) { + return ( + + + + {withAlert && ( + + + + )} + + ) +} diff --git a/apps/mobile/src/features/Signers/components/SignersList/SignersListItem.tsx b/apps/mobile/src/features/Signers/components/SignersList/SignersListItem.tsx new file mode 100644 index 000000000..62486e428 --- /dev/null +++ b/apps/mobile/src/features/Signers/components/SignersList/SignersListItem.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { shortenAddress } from '@/src/utils/formatters' +import { MenuView } 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' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SignerSection } from './SignersList' +import { View } from 'tamagui' +import { TouchableOpacity } from 'react-native-gesture-handler' +import { useRouter } from 'expo-router' + +interface SignersListItemProps { + item: AddressInfo + index: number + signersGroup: SignerSection[] +} + +function SignersListItem({ item, index, signersGroup }: SignersListItemProps) { + const router = useRouter() + const actions = useSignersActions() + const isLastItem = signersGroup.some((section) => section.data.length === index + 1) + + const onPress = () => { + router.push(`/signers/${item.value}`) + } + + return ( + + + + + + } + /> + + + ) +} + +export default SignersListItem diff --git a/apps/mobile/src/features/Signers/components/SignersList/hooks/useSignersActions.ts b/apps/mobile/src/features/Signers/components/SignersList/hooks/useSignersActions.ts new file mode 100644 index 000000000..d1a74f291 --- /dev/null +++ b/apps/mobile/src/features/Signers/components/SignersList/hooks/useSignersActions.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react' +import { Platform } from 'react-native' +import { useTheme } from 'tamagui' + +export const useSignersActions = () => { + const theme = useTheme() + const color = theme.color?.get() + const actions = useMemo( + () => [ + { + id: 'rename', + title: 'Rename', + image: Platform.select({ + ios: 'pencil', + android: 'baseline_create_24', + }), + imageColor: Platform.select({ ios: color, android: '#000' }), + }, + { + id: 'share', + title: 'Import signer', + image: Platform.select({ + ios: 'square.and.arrow.up.on.square', + android: 'baseline_arrow_outward_24', + }), + imageColor: Platform.select({ ios: color, android: '#000' }), + }, + ], + [color], + ) + + return actions +} diff --git a/apps/mobile/src/features/Signers/components/SignersList/index.ts b/apps/mobile/src/features/Signers/components/SignersList/index.ts new file mode 100644 index 000000000..79c4f1da2 --- /dev/null +++ b/apps/mobile/src/features/Signers/components/SignersList/index.ts @@ -0,0 +1,2 @@ +import { SignersList } from './SignersList' +export { SignersList } diff --git a/apps/mobile/src/features/Signers/constants.ts b/apps/mobile/src/features/Signers/constants.ts new file mode 100644 index 000000000..8ea64271c --- /dev/null +++ b/apps/mobile/src/features/Signers/constants.ts @@ -0,0 +1,12 @@ +import { SignerSection } from './components/SignersList/SignersList' + +export const groupedSigners: Record = { + imported: { + title: 'Imported signers', + data: [], + }, + notImported: { + 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 new file mode 100644 index 000000000..369cc45a0 --- /dev/null +++ b/apps/mobile/src/features/Signers/hooks/useSignersGroupService.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react' + +import { 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' + +export const useSignersGroupService = () => { + const activeSafe = useAppSelector(selectActiveSafe) + const appSigners = useAppSelector(selectSigners) + const { data, isFetching } = useSafesGetSafeV1Query({ + safeAddress: activeSafe.address, + chainId: activeSafe.chainId, + }) + + const group = useMemo(() => { + const sections = + data?.owners?.reduce( + (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]) + + return { group, isFetching } +} diff --git a/apps/mobile/src/features/Signers/index.tsx b/apps/mobile/src/features/Signers/index.tsx new file mode 100644 index 000000000..7a56ad945 --- /dev/null +++ b/apps/mobile/src/features/Signers/index.tsx @@ -0,0 +1,2 @@ +import { SignersContainer } from './Signers.container' +export { SignersContainer } diff --git a/apps/mobile/src/features/TxHistory/TxHistory.container.tsx b/apps/mobile/src/features/TxHistory/TxHistory.container.tsx new file mode 100644 index 000000000..1adb86a82 --- /dev/null +++ b/apps/mobile/src/features/TxHistory/TxHistory.container.tsx @@ -0,0 +1,38 @@ +import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import { safelyDecodeURIComponent } from 'expo-router/build/fork/getStateFromPath-forks' + +import { useGetTxsHistoryQuery } 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' + +export function TxHistoryContainer() { + const [pageUrl, setPageUrl] = useState() + const [list, setList] = useState([]) + 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 + } + + setList((prev) => [...prev, ...data.results]) + }, [data]) + + const onEndReached = () => { + if (!data?.next) { + return + } + + setPageUrl(data.next) + refetch() + } + + return +} diff --git a/apps/mobile/src/features/TxHistory/components/TxHistoryList/TxHistoryList.tsx b/apps/mobile/src/features/TxHistory/components/TxHistoryList/TxHistoryList.tsx new file mode 100644 index 000000000..9bbb500dd --- /dev/null +++ b/apps/mobile/src/features/TxHistory/components/TxHistoryList/TxHistoryList.tsx @@ -0,0 +1,36 @@ +import { Spinner } from 'tamagui' +import React, { useMemo } from 'react' +import { SectionList } from 'react-native' + +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' + +interface TxHistoryList { + transactions?: HistoryTransactionItems[] + onEndReached: (info: { distanceFromEnd: number }) => void + isLoading?: boolean +} + +export function TxHistoryList({ transactions, onEndReached, isLoading }: TxHistoryList) { + const groupedList: GroupedTxsWithTitle[] = useMemo(() => { + return groupTxsByDate(transactions || []) + }, [transactions]) + + return ( + (Array.isArray(item) ? getTxHash(item[0]) + index : getTxHash(item) + index)} + renderItem={renderItem} + onEndReached={onEndReached} + contentContainerStyle={{ paddingHorizontal: 16 }} + ListFooterComponent={isLoading ? : undefined} + renderSectionHeader={({ section: { title } }) => } + /> + ) +} diff --git a/apps/mobile/src/features/TxHistory/components/TxHistoryList/index.ts b/apps/mobile/src/features/TxHistory/components/TxHistoryList/index.ts new file mode 100644 index 000000000..86e3dcfe6 --- /dev/null +++ b/apps/mobile/src/features/TxHistory/components/TxHistoryList/index.ts @@ -0,0 +1,2 @@ +import { TxHistoryList } from './TxHistoryList' +export { TxHistoryList } diff --git a/apps/mobile/src/features/TxHistory/index.tsx b/apps/mobile/src/features/TxHistory/index.tsx new file mode 100644 index 000000000..be8a9b547 --- /dev/null +++ b/apps/mobile/src/features/TxHistory/index.tsx @@ -0,0 +1,2 @@ +import { TxHistoryContainer } from './TxHistory.container' +export { TxHistoryContainer } diff --git a/apps/mobile/src/features/TxHistory/utils.tsx b/apps/mobile/src/features/TxHistory/utils.tsx new file mode 100644 index 000000000..a29e92f8a --- /dev/null +++ b/apps/mobile/src/features/TxHistory/utils.tsx @@ -0,0 +1,63 @@ +import { DateLabel, TransactionItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { groupBulkTxs } from '@/src/utils/transactions' +import { formatWithSchema } from '@/src/utils/date' +import { isDateLabel } from '@/src/utils/transaction-guards' +import { HistoryTransactionItems } from '@safe-global/store/gateway/types' +import { View } from 'tamagui' +import { TxGroupedCard } from '@/src/components/transactions-list/Card/TxGroupedCard' +import { TxInfo } from '@/src/components/TxInfo' +import React from 'react' + +export type GroupedTxs = (T | T[])[] + +export interface GroupedTxsWithTitle { + title: string + data: (T | T[])[] +} + +export const groupTxsByDate = (list: HistoryTransactionItems[]) => { + return groupByDateLabel(groupBulkTxs(list)) +} + +const getDateLabel = (item: HistoryTransactionItems) => { + if (isDateLabel(item)) { + return formatWithSchema(item.timestamp, 'MMM d, yyyy') + } + return undefined +} + +const groupByDateLabel = ( + list: GroupedTxs, +): GroupedTxsWithTitle>[] => { + const groupedTx: GroupedTxsWithTitle>[] = [] + + list.forEach((item) => { + if (Array.isArray(item) || item.type === 'TRANSACTION') { + if (groupedTx.length === 0) { + groupedTx.push({ title: 'Unknown Date', data: [] }) + } + groupedTx[groupedTx.length - 1].data.push(item as Exclude) + } else { + const title = getDateLabel(item) + if (title) { + groupedTx.push({ title, data: [] }) + } + } + }) + + return groupedTx +} +export const getTxHash = (item: HistoryTransactionItems): string => { + if (item.type !== 'TRANSACTION') { + return '' + } + + return item.transaction.txHash as unknown as string +} +export const renderItem = ({ item, index }: { item: TransactionItem | TransactionItem[]; index: number }) => { + return ( + + {Array.isArray(item) ? : } + + ) +} diff --git a/apps/mobile/src/hooks/useCopyAndDispatchToast/index.ts b/apps/mobile/src/hooks/useCopyAndDispatchToast/index.ts new file mode 100644 index 000000000..1d3f365a7 --- /dev/null +++ b/apps/mobile/src/hooks/useCopyAndDispatchToast/index.ts @@ -0,0 +1,13 @@ +import { useToastController } from '@tamagui/toast' +import Clipboard from '@react-native-clipboard/clipboard' + +export const useCopyAndDispatchToast = () => { + const toast = useToastController() + return (value: string) => { + Clipboard.setString(value) + toast.show('Address copied.', { + native: false, + duration: 2000, + }) + } +} diff --git a/apps/mobile/src/hooks/useCopyAndDispatchToast/useCopyAndDisptachToast.test.tsx b/apps/mobile/src/hooks/useCopyAndDispatchToast/useCopyAndDisptachToast.test.tsx new file mode 100644 index 000000000..20126ff75 --- /dev/null +++ b/apps/mobile/src/hooks/useCopyAndDispatchToast/useCopyAndDisptachToast.test.tsx @@ -0,0 +1,50 @@ +import { renderHook, act } from '@/src/tests/test-utils' +import Clipboard from '@react-native-clipboard/clipboard' +import { useToastController } from '@tamagui/toast' +import { useCopyAndDispatchToast } from './index' + +jest.mock('@react-native-clipboard/clipboard', () => ({ + setString: jest.fn(), +})) + +jest.mock('@tamagui/toast', () => ({ + useToastController: jest.fn(), +})) + +describe('useCopyAndDispatchToast', () => { + const mockShow = jest.fn() + + beforeEach(() => { + ;(useToastController as jest.Mock).mockReturnValue({ + show: mockShow, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('copies the provided value to the clipboard', () => { + const { result } = renderHook(() => useCopyAndDispatchToast()) + const testValue = 'Test Clipboard Value' + + act(() => { + result.current(testValue) + }) + + expect(Clipboard.setString).toHaveBeenCalledWith(testValue) + }) + + it('displays a toast message after copying', () => { + const { result } = renderHook(() => useCopyAndDispatchToast()) + + act(() => { + result.current('Any Value') + }) + + expect(mockShow).toHaveBeenCalledWith('Address copied.', { + native: false, + duration: 2000, + }) + }) +}) diff --git a/apps/mobile/src/hooks/useInfiniteScroll/index.ts b/apps/mobile/src/hooks/useInfiniteScroll/index.ts new file mode 100644 index 000000000..02e13ff6f --- /dev/null +++ b/apps/mobile/src/hooks/useInfiniteScroll/index.ts @@ -0,0 +1 @@ +export { useInfiniteScroll } from './useInfiniteScroll' diff --git a/apps/mobile/src/hooks/useInfiniteScroll/useInfiniteScroll.ts b/apps/mobile/src/hooks/useInfiniteScroll/useInfiniteScroll.ts new file mode 100644 index 000000000..fc2290b3e --- /dev/null +++ b/apps/mobile/src/hooks/useInfiniteScroll/useInfiniteScroll.ts @@ -0,0 +1,39 @@ +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { useCallback, useEffect, useState } from 'react' +import { useSelector } from 'react-redux' + +type TUseInfiniteScrollData = { results: J[]; next?: string | null } + +type TUseInfiniteScrollConfig = { + refetch: () => void + setPageUrl: (nextUrl?: string) => void + data: (T & TUseInfiniteScrollData) | undefined +} + +export const useInfiniteScroll = ({ refetch, setPageUrl, data }: TUseInfiniteScrollConfig) => { + const activeSafe = useSelector(selectActiveSafe) + const [list, setList] = useState([]) + + useEffect(() => { + setList([]) + }, [activeSafe]) + + useEffect(() => { + if (!data?.results) { + return + } + + setList((prev) => (prev ? [...prev, ...data.results] : data.results)) + }, [data]) + + const onEndReached = useCallback(() => { + if (!data?.next) { + return + } + + setPageUrl(data.next) + refetch() + }, [data, refetch, setPageUrl]) + + return { list, onEndReached } +} diff --git a/apps/mobile/src/hooks/useNotifications.ts b/apps/mobile/src/hooks/useNotifications.ts new file mode 100644 index 000000000..0ea029e9b --- /dev/null +++ b/apps/mobile/src/hooks/useNotifications.ts @@ -0,0 +1,64 @@ +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 new file mode 100644 index 000000000..16ac8c0d7 --- /dev/null +++ b/apps/mobile/src/hooks/usePendingTxs/index.ts @@ -0,0 +1,50 @@ +import { useGetPendingTxsQuery } from '@safe-global/store/gateway' +import { useMemo, useState } from 'react' +import { useSelector } from 'react-redux' +import { + ConflictHeaderQueuedItem, + LabelQueuedItem, + QueuedItemPage, + 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' + +const usePendingTxs = () => { + const activeSafe = useSelector(selectActiveSafe) + const [pageUrl, setPageUrl] = useState() + + const { data, isLoading, isFetching, refetch, isUninitialized } = useGetPendingTxsQuery( + { + chainId: activeSafe.chainId, + safeAddress: activeSafe.address, + cursor: pageUrl && safelyDecodeURIComponent(pageUrl?.split('cursor=')[1]), + }, + { + skip: !activeSafe.chainId, + }, + ) + const { list, onEndReached: fetchMoreTx } = useInfiniteScroll< + QueuedItemPage, + ConflictHeaderQueuedItem | LabelQueuedItem | TransactionQueuedItem + >({ + refetch, + setPageUrl, + data, + }) + + const pendingTxs = useMemo(() => groupPendingTxs(list || []), [list]) + + return { + hasMore: Boolean(data?.next), + amount: pendingTxs.amount, + data: pendingTxs.sections, + fetchMoreTx, + isLoading: isLoading || isUninitialized, + isFetching: isFetching, + } +} + +export default usePendingTxs diff --git a/apps/mobile/src/hooks/useSign/index.ts b/apps/mobile/src/hooks/useSign/index.ts new file mode 100644 index 000000000..26d610020 --- /dev/null +++ b/apps/mobile/src/hooks/useSign/index.ts @@ -0,0 +1 @@ +export { useSign } from './useSign' diff --git a/apps/mobile/src/hooks/useSign/useSign.test.ts b/apps/mobile/src/hooks/useSign/useSign.test.ts new file mode 100644 index 000000000..342f5bea8 --- /dev/null +++ b/apps/mobile/src/hooks/useSign/useSign.test.ts @@ -0,0 +1,65 @@ +import { act, renderHook } from '@/src/tests/test-utils' +import { asymmetricKey, keychainGenericPassword, useSign } from './useSign' +import { HDNodeWallet, Wallet } from 'ethers' +import * as Keychain from 'react-native-keychain' +import DeviceCrypto from 'react-native-device-crypto' + +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) + }) + + expect(asymmetricKeySpy).toHaveBeenCalledWith(asymmetricKey, { accessLevel: 2, invalidateOnNewBiometry: true }) + expect(encryptSpy).toHaveBeenCalledWith(asymmetricKey, privateKey, { + biometryTitle: 'Authenticate', + biometrySubTitle: 'Saving key', + biometryDescription: 'Please authenticate yourself', + }) + 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 + + // To generate the iv and wait till the hook re-renders + await act(async () => { + await result.current.storePrivateKey(privateKey) + }) + + await act(async () => { + returnedKey = await result.current.getPrivateKey() + }) + + expect(spy).toHaveBeenCalledWith( + 'safeuser', + JSON.stringify({ encryptyedPassword: 'encryptedText', iv: `${privateKey}000` }), + ) + expect(returnedKey).toBe(privateKey) + }) + + it('should import a wallet when given a mnemonic phrase', async () => { + const { result } = renderHook(() => useSign()) + const { mnemonic, privateKey } = Wallet.createRandom() + + // To generate the iv and wait till the hook re-renders + await act(async () => { + const wallet = await result.current.createMnemonicAccount(mnemonic?.phrase as string) + + expect(wallet).toBeInstanceOf(HDNodeWallet) + expect(wallet?.privateKey).toBe(privateKey) + }) + }) +}) diff --git a/apps/mobile/src/hooks/useSign/useSign.ts b/apps/mobile/src/hooks/useSign/useSign.ts new file mode 100644 index 000000000..a6030bf6d --- /dev/null +++ b/apps/mobile/src/hooks/useSign/useSign.ts @@ -0,0 +1,76 @@ +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' + +export const asymmetricKey = 'safe' +export const keychainGenericPassword = 'safeuser' + +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 { 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 + } + + return Wallet.fromPhrase(mnemonic) + } catch (err) { + console.log(err) + } + } + + return { + storePrivateKey, + getPrivateKey, + createMnemonicAccount, + } +} diff --git a/apps/mobile/src/hooks/useTransactionType/index.tsx b/apps/mobile/src/hooks/useTransactionType/index.tsx new file mode 100644 index 000000000..f7d39b775 --- /dev/null +++ b/apps/mobile/src/hooks/useTransactionType/index.tsx @@ -0,0 +1,156 @@ +import { useMemo } from 'react' +import type { AnyAppDataDocVersion, latest } from '@cowprotocol/app-data' +import { SettingsInfoType, TransactionInfoType } from '@safe-global/store/gateway/types' +import type { Transaction, AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { + isCancellationTxInfo, + isModuleExecutionInfo, + isMultiSendTxInfo, + isOutgoingTransfer, + isTxQueued, +} from '@/src/utils/transaction-guards' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { SwapOrderTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const getTxTo = ({ txInfo }: Pick): AddressInfo | undefined => { + switch (txInfo.type) { + case TransactionInfoType.CREATION: { + return txInfo.factory + } + case TransactionInfoType.TRANSFER: { + return txInfo.recipient + } + case TransactionInfoType.SETTINGS_CHANGE: { + return undefined + } + case TransactionInfoType.CUSTOM: { + return txInfo.to + } + } +} + +interface TxType { + text: string + icon?: string | React.ReactElement + image: string | React.ReactElement +} + +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 getTransactionType = (tx: Transaction): TxType => { + const toAddress = getTxTo(tx) + + switch (tx.txInfo.type) { + case TransactionInfoType.CREATION: { + return { + image: toAddress?.logoUri || , + icon: toAddress?.logoUri || , + text: 'Safe Account created', + } + } + case TransactionInfoType.SWAP_TRANSFER: + case TransactionInfoType.TRANSFER: { + const isSendTx = isOutgoingTransfer(tx.txInfo) + const icon = isSendTx ? ( + + ) : ( + + ) + return { + icon, + image: 'https://safe-transaction-assets.safe.global/chains/1/currency_logo.png', + text: isSendTx ? (isTxQueued(tx.txStatus) ? 'Send' : 'Sent') : 'Received', + } + } + case TransactionInfoType.SETTINGS_CHANGE: { + // deleteGuard doesn't exist in Solidity + // It is decoded as 'setGuard' with a settingsInfo.type of 'DELETE_GUARD' + const isDeleteGuard = tx.txInfo.settingsInfo?.type === SettingsInfoType.DELETE_GUARD + + return { + image: , + icon: , + text: isDeleteGuard ? 'deleteGuard' : tx.txInfo.dataDecoded.method, + } + } + + case TransactionInfoType.SWAP_ORDER: { + const orderClass = getOrderClass(tx.txInfo) + const altText = orderClass === 'limit' ? 'Limit order' : 'Swap order' + + return { + image: , + icon: , + text: altText, + } + } + case TransactionInfoType.TWAP_ORDER: { + return { + image: , + icon: , + text: 'TWAP order', + } + } + case TransactionInfoType.CUSTOM: { + if (isMultiSendTxInfo(tx.txInfo) && !tx.safeAppInfo) { + return { + image: , + icon: , + text: 'Batch', + } + } + + if (isModuleExecutionInfo(tx.executionInfo)) { + return { + image: toAddress?.logoUri || , + icon: , + text: toAddress?.name || 'Contract interaction', + } + } + + if (isCancellationTxInfo(tx.txInfo)) { + return { + image: , + icon: , + text: 'On-chain rejection', + } + } + + return { + image: toAddress?.logoUri || , + icon: , + text: toAddress?.name || 'Contract interaction', + } + } + default: { + if (tx.safeAppInfo) { + return { + image: tx.safeAppInfo.logoUri || '', + icon: , + text: tx.safeAppInfo.name, + } + } + + return { + icon: , + image: , + text: 'Contract interaction', + } + } + } +} + +// We're going to need the address book in the future +// rename it to useTransactionNormalizer +export const useTransactionType = (tx: Transaction): TxType => { + // addressBook = useAddressBook + + return useMemo(() => { + return getTransactionType(tx) + }, [tx]) +} diff --git a/apps/mobile/src/hooks/useTransactionType/useTransactionType.test.tsx b/apps/mobile/src/hooks/useTransactionType/useTransactionType.test.tsx new file mode 100644 index 000000000..429fb848c --- /dev/null +++ b/apps/mobile/src/hooks/useTransactionType/useTransactionType.test.tsx @@ -0,0 +1,161 @@ +import { renderHook } from '@/src/tests/test-utils' +import { useTransactionType } from '.' +import { mockTransactionSummary, mockTransferWithInfo } from '@/src/tests/mocks' +import { TransactionInfoType, TransactionStatus, TransferDirection } from '@safe-global/store/gateway/types' + +describe('useTransactionType', () => { + it('should be a Received transaction', () => { + const { result } = renderHook(() => useTransactionType(mockTransactionSummary)) + + expect(result.current.text).toBe('Received') + }) + + it('should be a Creation transaction', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.CREATION, + }), + }), + ) + + expect(result.current.text).toBe('Safe Account created') + }) + + it('should be a outgoing transfer transaction', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.TRANSFER, + direction: TransferDirection.OUTGOING, + }), + }), + ) + + expect(result.current.text).toBe('Sent') + }) + + it('should be a outgoing transfer transaction awaiting for execution', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txStatus: TransactionStatus.AWAITING_EXECUTION, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.TRANSFER, + direction: TransferDirection.OUTGOING, + }), + }), + ) + + expect(result.current.text).toBe('Send') + }) + + it('should return the type for a SETTINGS_CHANGE transaction', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.SETTINGS_CHANGE, + }), + }), + ) + + expect(result.current.text).toBe('mockMethod') + }) + + it('should return the type for a SWAP_ORDER transaction', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.SWAP_ORDER, + }), + }), + ) + + expect(result.current.text).toBe('Swap order') + }) + + it('should return the type for a TWAP_ORDER transaction', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.TWAP_ORDER, + }), + }), + ) + + expect(result.current.text).toBe('TWAP order') + }) + + it('should return the type for a CUSTOM transaction', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.CUSTOM, + }), + }), + ) + + expect(result.current.text).toBe('Contract interaction') + }) + + it('should return a `Batch` text for a CUSTOM batch transaction', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockTransferWithInfo({ + type: TransactionInfoType.CUSTOM, + methodName: 'multiSend', + actionCount: 2, + }), + safeAppInfo: undefined, + }), + ) + + expect(result.current.text).toBe('Batch') + }) + + it('should return the default transaction information', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockTransferWithInfo({ + type: 'something else' as TransactionInfoType, + }), + safeAppInfo: undefined, + }), + ) + + expect(result.current.text).toBe('Contract interaction') + }) + + it('should return the default transaction information with safe information', () => { + const { result } = renderHook(() => + useTransactionType({ + ...mockTransactionSummary, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockTransferWithInfo({ + type: 'something else' as TransactionInfoType, + }), + safeAppInfo: { + name: 'somename', + url: 'http://google.com', + logoUri: 'myurl.com', + }, + }), + ) + + expect(result.current.text).toBe('somename') + }) +}) diff --git a/apps/mobile/src/navigation/useScrollableHeader.tsx b/apps/mobile/src/navigation/useScrollableHeader.tsx new file mode 100644 index 000000000..9df2b0546 --- /dev/null +++ b/apps/mobile/src/navigation/useScrollableHeader.tsx @@ -0,0 +1,50 @@ +// useScrollableHeader.ts +import { useEffect } from 'react' +import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native' +import { useNavigation } from 'expo-router' +import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated' + +interface UseScrollableHeaderProps { + children: React.ReactNode + scrollYThreshold?: number // Default threshold for opacity change +} + +/** + * https://reactnavigation.org/docs/native-stack-navigator/#headerlargetitle + * HeaderLargeTitle only works when the header title is a string. + * If one tries to pass a component as a header title, the LargeHeaderTitle will not work. + * + * This hook is a workaround to use a custom component as a header title and update the opacity of the header dynamically. + * + * @param children + * @param scrollYThreshold + */ +export const useScrollableHeader = ({ children, scrollYThreshold = 37 }: UseScrollableHeaderProps) => { + const navigation = useNavigation() + const opacity = useSharedValue(0) + + // Update navigation header title dynamically + useEffect(() => { + navigation.setOptions({ + headerTitle: () => ( + + {children} + + ), + }) + }, [navigation, children]) + + const animatedHeaderStyle = useAnimatedStyle(() => ({ + opacity: withTiming(opacity.value, { duration: 300 }), + })) + + // Scroll event handler for updating opacity + const handleScroll = (event: NativeSyntheticEvent) => { + const scrollY = event.nativeEvent.contentOffset.y + opacity.value = scrollY > scrollYThreshold ? 1 : 0 + } + + return { + handleScroll, + } +} diff --git a/apps/mobile/src/react-app-env.d.ts b/apps/mobile/src/react-app-env.d.ts new file mode 100644 index 000000000..be0fb4f82 --- /dev/null +++ b/apps/mobile/src/react-app-env.d.ts @@ -0,0 +1,5 @@ +declare module '*.png' +declare module '*.svg' +declare module '*.jpeg' +declare module '*.jpg' +declare module '*.ttf' diff --git a/apps/mobile/src/services/exceptions/utils.test.ts b/apps/mobile/src/services/exceptions/utils.test.ts new file mode 100644 index 000000000..0c6b91f76 --- /dev/null +++ b/apps/mobile/src/services/exceptions/utils.test.ts @@ -0,0 +1,33 @@ +import { asError } from './utils' + +describe('Exceptions Utils', () => { + it('should throw an error from an Error instance', () => { + const message = 'This is an error message' + const errorFn = () => { + throw asError(new Error(message)) + } + + expect(errorFn).toThrow(Error) + expect(errorFn).toThrow(message) + }) + + it('should throw an Error from a json string', () => { + const message = { myError: 'something', nested: { id: 1 } } + const errorFn = () => { + throw asError(message) + } + + expect(errorFn).toThrow(Error) + expect(errorFn).toThrow(JSON.stringify(message)) + }) + + it('should throw an Error from a string', () => { + const message = 'some error' + const errorFn = () => { + throw asError(message) + } + + expect(errorFn).toThrow(Error) + expect(errorFn).toThrow(message) + }) +}) diff --git a/src/services/exceptions/utils.ts b/apps/mobile/src/services/exceptions/utils.ts similarity index 100% rename from src/services/exceptions/utils.ts rename to apps/mobile/src/services/exceptions/utils.ts diff --git a/apps/mobile/src/services/notifications/FCMService.ts b/apps/mobile/src/services/notifications/FCMService.ts new file mode 100644 index 000000000..00bcf4ddc --- /dev/null +++ b/apps/mobile/src/services/notifications/FCMService.ts @@ -0,0 +1,67 @@ +import messaging, { FirebaseMessagingTypes } 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 + +class FCMService { + async getFCMToken(): Promise { + const { fcmToken } = store.getState().notifications + const token = fcmToken || undefined + if (!token) { + Logger.info('getFCMToken: No FCM token found') + } + return token + } + + async saveFCMToken(): Promise { + try { + const fcmToken = await messaging().getToken() + if (fcmToken) { + store.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 { + if (!messaging().registerDeviceForRemoteMessages) { + await messaging() + .registerDeviceForRemoteMessages() + .then((status: unknown) => { + Logger.info('registerDeviceForRemoteMessages status', status) + }) + .catch((error) => { + Logger.error('registerAppWithFCM: Something went wrong', error) + }) + } + } +} +export default new FCMService() diff --git a/apps/mobile/src/services/notifications/NotificationService.ts b/apps/mobile/src/services/notifications/NotificationService.ts new file mode 100644 index 000000000..317ab6c5b --- /dev/null +++ b/apps/mobile/src/services/notifications/NotificationService.ts @@ -0,0 +1,285 @@ +import notifee, { + AuthorizationStatus, + Event as NotifeeEvent, + EventType, + EventDetail, + AndroidChannel, +} from '@notifee/react-native' +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 { 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' + +interface AlertButton { + text: string + onPress: () => void | Promise +} + +class NotificationsService { + async getBlockedNotifications(): Promise> { + try { + const settings = await notifee.getNotificationSettings() + const channels = await notifee.getChannels() + + switch (settings.authorizationStatus) { + case AuthorizationStatus.NOT_DETERMINED: + case AuthorizationStatus.DENIED: + return notificationChannels.reduce((map, next) => { + map.set(next.id as ChannelId, true) + return map + }, new Map()) + } + + return channels.reduce((map, next) => { + if (next.blocked) { + map.set(next.id as ChannelId, true) + } + return map + }, new Map()) + } catch (error) { + Logger.error('Error checking if a user has push notifications permission', error) + return new Map() + } + } + + async getAllPermissions(shouldOpenSettings = true) { + try { + const promises: Promise[] = 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 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 + **/ + if ((permission !== 'authorized' || blockedNotifications.size !== 0) && shouldOpenSettings) { + await this.requestPushNotificationsPermission() + permission = await withTimeout(this.checkCurrentPermissions(), 5000) + } + return { permission, blockedNotifications } + } catch (error) { + Logger.error('Error occurred while fetching permissions:', error) + + return { permission: 'denied', blockedNotifications: new Set() } + } + } + + async isDeviceNotificationEnabled() { + const permission = await notifee.getNotificationSettings() + + const isAuthorized = + permission.authorizationStatus === AuthorizationStatus.AUTHORIZED || + permission.authorizationStatus === AuthorizationStatus.PROVISIONAL + + return isAuthorized + } + + defaultButtons = (resolve: (value: boolean) => void): AlertButton[] => [ + { + text: 'Maybe later', + onPress: () => { + /** + * 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)') + + 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() + resolve(true) + }, + }, + ] + + asyncAlert = ( + title: string, + msg: string, + getButtons: (resolve: (value: boolean) => void) => AlertButton[] = this.defaultButtons, + ): Promise => + new Promise((resolve) => { + NativeAlert.alert(title, msg, getButtons(resolve), { + cancelable: false, + }) + }) + + async requestPushNotificationsPermission(): Promise { + try { + await this.asyncAlert( + 'Enable Push Notifications', + 'Turn on notifications from Settings to get important alerts on wallet activity and more.', + ) + } catch (error) { + Logger.error('Error checking if a user has push notifications permission', error) + } + } + + openSystemSettings() { + if (Platform.OS === 'ios') { + Linking.openSettings() + } else { + notifee.openNotificationSettings() + } + } + + async checkCurrentPermissions() { + const settings = await notifee.getNotificationSettings() + return settings.authorizationStatus === AuthorizationStatus.AUTHORIZED || + settings.authorizationStatus === AuthorizationStatus.PROVISIONAL + ? 'authorized' + : 'denied' + } + + onForegroundEvent(observer: (event: NotifeeEvent) => Promise): () => void { + return notifee.onForegroundEvent(observer) + } + + onBackgroundEvent(observer: (event: NotifeeEvent) => Promise) { + return notifee.onBackgroundEvent(observer) + } + + async incrementBadgeCount(incrementBy?: number) { + return await notifee.incrementBadgeCount(incrementBy) + } + + async decrementBadgeCount(decrementBy?: number) { + return await notifee.decrementBadgeCount(decrementBy) + } + + async setBadgeCount(count: number) { + return await notifee.setBadgeCount(count) + } + + async getBadgeCount() { + return await notifee.getBadgeCount() + } + + async handleNotificationPress({ + detail, + callback, + }: { + detail: EventDetail + callback?: (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => void + }) { + this.decrementBadgeCount(1) + if (detail?.notification?.id) { + await this.cancelTriggerNotification(detail.notification.id) + } + + if (detail?.notification?.data) { + callback?.(detail.notification as FirebaseMessagingTypes.RemoteMessage) + } + } + + async handleNotificationEvent({ + type, + detail, + callback, + }: NotifeeEvent & { + callback?: (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => void + }) { + switch (type as unknown as EventType) { + case EventType.DELIVERED: + this.incrementBadgeCount(1) + break + case EventType.PRESS: + this.handleNotificationPress({ + detail, + callback, + }) + break + } + } + + async cancelTriggerNotification(id?: string) { + if (!id) { + return + } + await notifee.cancelTriggerNotification(id) + } + + async getInitialNotification(callback: HandleNotificationCallback): Promise { + const event = await notifee.getInitialNotification() + if (event) { + callback(event.notification.data as Notification['data']) + } + } + + async cancelAllNotifications() { + await notifee.cancelAllNotifications() + } + + async createChannel(channel: AndroidChannel): Promise { + return await notifee.createChannel(channel) + } + + async displayNotification({ + channelId, + title, + body, + data, + }: { + channelId: ChannelId + title: string + body?: string + data?: FirebaseMessagingTypes.RemoteMessage['data'] + }): Promise { + try { + await notifee.displayNotification({ + title, + body, + data, + android: { + smallIcon: 'ic_notification_small', + largeIcon: 'ic_notification', + channelId: channelId ?? ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, + pressAction: { + id: PressActionId.OPEN_NOTIFICATIONS_VIEW, + launchActivity: LAUNCH_ACTIVITY, + }, + }, + ios: { + launchImageName: 'Default', + sound: 'default', + interruptionLevel: 'critical', + foregroundPresentationOptions: { + alert: true, + sound: true, + badge: true, + banner: true, + list: true, + }, + }, + }) + } catch (error) { + Logger.error('NotificationService.displayNotification :: error', error) + } + } +} + +export default new NotificationsService() diff --git a/apps/mobile/src/store/activeSafeSlice.ts b/apps/mobile/src/store/activeSafeSlice.ts new file mode 100644 index 000000000..6a1bc19cb --- /dev/null +++ b/apps/mobile/src/store/activeSafeSlice.ts @@ -0,0 +1,34 @@ +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 activeSafeSlice = createSlice({ + name: 'activeSafe', + initialState, + reducers: { + setActiveSafe: (state, action: PayloadAction) => { + return action.payload + }, + clearActiveSafe: () => { + return initialState + }, + switchActiveChain: (state, action: PayloadAction<{ chainId: string }>) => { + return { + ...state, + chainId: action.payload.chainId, + } + }, + }, +}) + +export const { setActiveSafe, switchActiveChain, clearActiveSafe } = activeSafeSlice.actions + +export const selectActiveSafe = (state: RootState) => state.activeSafe + +export default activeSafeSlice.reducer diff --git a/apps/mobile/src/store/chains/index.ts b/apps/mobile/src/store/chains/index.ts new file mode 100644 index 000000000..f123ccc14 --- /dev/null +++ b/apps/mobile/src/store/chains/index.ts @@ -0,0 +1,37 @@ +import { apiSliceWithChainsConfig, chainsAdapter, initialState } from '@safe-global/store/gateway/chains' +import { createSelector } from '@reduxjs/toolkit' +import { RootState } from '..' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { selectActiveSafe } from '../activeSafeSlice' + +const selectChainsResult = apiSliceWithChainsConfig.endpoints.getChainsConfig.select() + +const selectChainsData = createSelector(selectChainsResult, (result) => { + return result.data ?? initialState +}) + +const { selectAll: selectAllChains, selectById } = chainsAdapter.getSelectors(selectChainsData) + +export const selectChainById = (state: RootState, chainId: string) => selectById(state, chainId) +export const selectAllChainsIds = createSelector([selectAllChains], (chains: Chain[]) => + chains.map((chain) => chain.chainId), +) +export const selectActiveChainCurrency = createSelector( + [selectActiveSafe, (state: RootState) => state], + (activeSafe, state) => { + const chain = selectChainById(state, activeSafe.chainId) + return chain?.nativeCurrency + }, +) + +export const getChainsByIds = createSelector( + [ + // Pass the root state and chainIds array as dependencies + (state: RootState) => state, + (_state: RootState, chainIds: string[]) => chainIds, + ], + (state, chainIds) => chainIds.map((chainId) => selectById(state, chainId)), +) + +export const { useGetChainsConfigQuery } = apiSliceWithChainsConfig +export { selectAllChains } diff --git a/apps/mobile/src/store/constants.ts b/apps/mobile/src/store/constants.ts new file mode 100644 index 000000000..aa705b94d --- /dev/null +++ b/apps/mobile/src/store/constants.ts @@ -0,0 +1,345 @@ +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { SafeInfo } from '../types/address' +import { FirebaseMessagingTypes } from '@react-native-firebase/messaging' +import { Dimensions } from 'react-native' + +export const WINDOW_HEIGHT = Dimensions.get('window').height +export const WINDOW_WIDTH = Dimensions.get('window').width +export const Layout = { + window: { + width: WINDOW_WIDTH, + height: WINDOW_HEIGHT, + }, + isSmallDevice: WINDOW_WIDTH < 375, +} + +export const mockedActiveAccount: SafeInfo = { + address: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + chainId: '1', +} + +export const mockedActiveSafeInfo: SafeOverview = { + address: { value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', name: null, logoUri: null }, + awaitingConfirmation: null, + chainId: mockedActiveAccount.chainId, + fiatTotal: '758.926', + owners: [{ value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', name: null, logoUri: null }], + queued: 1, + threshold: 1, +} + +export const mockedAccounts = [ + mockedActiveSafeInfo, + { + address: { value: '0xc7c2E116A3027D0BFd9817781c717A81a8bC5518', name: null, logoUri: null }, + awaitingConfirmation: null, + chainId: '42161', + fiatTotal: '0', + owners: [{ value: '0xc7c2E116A3027D0BFd9817781c717A81a8bC5518', name: null, logoUri: null }], + queued: 1, + threshold: 1, + }, + { + address: { value: '0xF7a47Bf5705572B7EB9cb0F7007C66B770Ea120f', name: null, logoUri: null }, + awaitingConfirmation: null, + chainId: '100', + fiatTotal: '0', + owners: [{ value: '0xF7a47Bf5705572B7EB9cb0F7007C66B770Ea120f', name: null, logoUri: null }], + queued: 1, + threshold: 1, + }, + { + address: { value: '0x7bF7cF1D8375ad2B25B9050FeF93181ec3E15f08', name: null, logoUri: null }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners: [{ value: '0x7bF7cF1D8375ad2B25B9050FeF93181ec3E15f08', name: null, logoUri: null }], + queued: 1, + threshold: 1, + }, +] + +export const mockedChains = [ + { + balancesProvider: { chainName: 'xdai', enabled: true }, + beaconChainExplorerUriTemplate: { publicKey: null }, + blockExplorerUriTemplate: { + address: 'https://gnosisscan.io/address/{{address}}', + api: 'https://api.gnosisscan.io/api?module={{module}}&action={{action}}&address={{address}}&apiKey={{apiKey}}', + txHash: 'https://gnosisscan.io/tx/{{txHash}}/', + }, + chainId: '100', + chainLogoUri: 'https://safe-transaction-assets.safe.global/chains/100/chain_logo.png', + chainName: 'Gnosis Chain', + contractAddresses: { + createCallAddress: null, + fallbackHandlerAddress: null, + multiSendAddress: null, + multiSendCallOnlyAddress: null, + safeProxyFactoryAddress: null, + safeSingletonAddress: null, + safeWebAuthnSignerFactoryAddress: null, + signMessageLibAddress: null, + simulateTxAccessorAddress: null, + }, + description: '', + disabledWallets: [ + 'keystone', + 'ledger_v2', + 'NONE', + 'opera', + 'operaTouch', + 'pk', + 'safeMobile', + 'tally', + 'trust', + 'walletConnect', + ], + ensRegistryAddress: null, + features: [ + 'COUNTERFACTUAL', + 'DEFAULT_TOKENLIST', + 'DELETE_TX', + 'EIP1271', + 'EIP1559', + 'ERC721', + 'MULTI_CHAIN_SAFE_ADD_NETWORK', + 'MULTI_CHAIN_SAFE_CREATION', + 'NATIVE_SWAPS', + 'NATIVE_SWAPS_FEE_ENABLED', + 'NATIVE_WALLETCONNECT', + 'PROPOSERS', + 'PUSH_NOTIFICATIONS', + 'RECOVERY', + 'RELAYING', + 'RELAYING_MOBILE', + 'RISK_MITIGATION', + 'SAFE_141', + 'SAFE_APPS', + 'SPEED_UP_TX', + 'SPENDING_LIMIT', + 'TX_SIMULATION', + 'ZODIAC_ROLES', + ], + gasPrice: [], + isTestnet: false, + l2: true, + nativeCurrency: { + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/100/currency_logo.png', + name: 'xDai', + symbol: 'XDAI', + }, + publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://rpc.gnosischain.com/' }, + rpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://rpc.gnosischain.com/' }, + safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://rpc.gnosischain.com/' }, + shortName: 'gno', + theme: { backgroundColor: '#48A9A6', textColor: '#ffffff' }, + transactionService: 'https://safe-transaction-gnosis-chain.safe.global', + }, + { + balancesProvider: { chainName: 'polygon', enabled: true }, + beaconChainExplorerUriTemplate: { publicKey: null }, + blockExplorerUriTemplate: { + address: 'https://polygonscan.com/address/{{address}}', + api: 'https://api.polygonscan.com/api?module={{module}}&action={{action}}&address={{address}}&apiKey={{apiKey}}', + txHash: 'https://polygonscan.com/tx/{{txHash}}', + }, + chainId: '137', + chainLogoUri: 'https://safe-transaction-assets.safe.global/chains/137/chain_logo.png', + chainName: 'Polygon', + contractAddresses: { + createCallAddress: null, + fallbackHandlerAddress: null, + multiSendAddress: null, + multiSendCallOnlyAddress: null, + safeProxyFactoryAddress: null, + safeSingletonAddress: null, + safeWebAuthnSignerFactoryAddress: null, + signMessageLibAddress: null, + simulateTxAccessorAddress: null, + }, + description: 'L2 chain', + disabledWallets: [ + 'keystone', + 'ledger_v2', + 'NONE', + 'opera', + 'operaTouch', + 'pk', + 'safeMobile', + 'socialSigner', + 'tally', + 'trezor', + 'trust', + 'walletConnect', + ], + ensRegistryAddress: null, + features: [ + 'COUNTERFACTUAL', + 'DEFAULT_TOKENLIST', + 'DELETE_TX', + 'EIP1271', + 'EIP1559', + 'ERC721', + 'MOONPAY_MOBILE', + 'MULTI_CHAIN_SAFE_ADD_NETWORK', + 'MULTI_CHAIN_SAFE_CREATION', + 'NATIVE_WALLETCONNECT', + 'PROPOSERS', + 'PUSH_NOTIFICATIONS', + 'RECOVERY', + 'RELAYING', + 'RISK_MITIGATION', + 'SAFE_141', + 'SAFE_APPS', + 'SPEED_UP_TX', + 'SPENDING_LIMIT', + 'TX_SIMULATION', + 'ZODIAC_ROLES', + ], + gasPrice: [], + isTestnet: false, + l2: true, + nativeCurrency: { + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/137/currency_logo.png', + name: 'POL (ex-MATIC)', + symbol: 'POL', + }, + publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://polygon-rpc.com' }, + rpcUri: { authentication: 'API_KEY_PATH', value: 'https://polygon-mainnet.infura.io/v3/' }, + safeAppsRpcUri: { authentication: 'API_KEY_PATH', value: 'https://polygon-mainnet.infura.io/v3/' }, + shortName: 'matic', + theme: { backgroundColor: '#8248E5', textColor: '#ffffff' }, + transactionService: 'https://safe-transaction-polygon.safe.global', + }, + { + balancesProvider: { chainName: 'arbitrum', enabled: true }, + beaconChainExplorerUriTemplate: { publicKey: null }, + blockExplorerUriTemplate: { + address: 'https://arbiscan.io/address/{{address}}', + api: 'https://api.arbiscan.io/api?module={{module}}&action={{action}}&address={{address}}&apiKey={{apiKey}}', + txHash: 'https://arbiscan.io/tx/{{txHash}}', + }, + chainId: '42161', + chainLogoUri: 'https://safe-transaction-assets.safe.global/chains/42161/chain_logo.png', + chainName: 'Arbitrum', + contractAddresses: { + createCallAddress: null, + fallbackHandlerAddress: null, + multiSendAddress: null, + multiSendCallOnlyAddress: null, + safeProxyFactoryAddress: null, + safeSingletonAddress: null, + safeWebAuthnSignerFactoryAddress: null, + signMessageLibAddress: null, + simulateTxAccessorAddress: null, + }, + description: '', + disabledWallets: [ + 'keystone', + 'ledger_v2', + 'NONE', + 'opera', + 'operaTouch', + 'pk', + 'safeMobile', + 'socialSigner', + 'tally', + 'trust', + 'walletConnect', + ], + ensRegistryAddress: null, + features: [ + 'COUNTERFACTUAL', + 'DEFAULT_TOKENLIST', + 'DELETE_TX', + 'EIP1271', + 'ERC721', + 'MOONPAY_MOBILE', + 'MULTI_CHAIN_SAFE_ADD_NETWORK', + 'MULTI_CHAIN_SAFE_CREATION', + 'NATIVE_SWAPS', + 'NATIVE_SWAPS_FEE_ENABLED', + 'NATIVE_WALLETCONNECT', + 'PROPOSERS', + 'PUSH_NOTIFICATIONS', + 'RECOVERY', + 'RISK_MITIGATION', + 'SAFE_141', + 'SAFE_APPS', + 'SPEED_UP_TX', + 'TX_SIMULATION', + 'ZODIAC_ROLES', + ], + gasPrice: [], + isTestnet: false, + l2: true, + nativeCurrency: { + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/42161/currency_logo.png', + name: 'AETH', + symbol: 'AETH', + }, + publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://arb1.arbitrum.io/rpc' }, + rpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://arb1.arbitrum.io/rpc' }, + safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://arb1.arbitrum.io/rpc' }, + shortName: 'arb1', + theme: { backgroundColor: '#28A0F0', textColor: '#ffffff' }, + transactionService: 'https://safe-transaction-arbitrum.safe.global', + }, +] + +export enum STORAGE_IDS { + SAFE = 'safe', + NOTIFICATIONS = 'notifications', + GLOBAL_PUSH_NOTIFICATION_SETTINGS = 'globalNotificationSettings', + SAFE_FCM_TOKEN = 'safeFcmToken', + PUSH_NOTIFICATIONS_PROMPT_COUNT = 'pushNotificationsPromptCount', + PUSH_NOTIFICATIONS_PROMPT_TIME = 'pushNotificationsPromptTime', + DEVICE_ID_STORAGE_KEY = 'pns=deviceId', + DEFAULT_NOTIFICATION_CHANNEL_ID = 'DEFAULT_NOTIFICATION_CHANNEL_ID', + ANNOUNCEMENT_NOTIFICATION_CHANNEL_ID = 'ANNOUNCEMENT_NOTIFICATION_CHANNEL_ID', + DEFAULT_PUSH_NOTIFICATION_CHANNEL_PRIORITY = 'high', + REQUEST_PERMISSION_ASKED = 'REQUEST_PERMISSION_ASKED', + REQUEST_PERMISSION_GRANTED = 'REQUEST_PERMISSION_GRANTED', + NOTIFICATION_DATE_FORMAT = 'DD/MM/YYYY HH:mm:ss', + NOTIFICATIONS_SETTINGS = 'notifications-settings', + PN_USER_STORAGE = 'safePnUserStorage', +} + +export enum STORAGE_TYPES { + STRING = 'string', + BOOLEAN = 'boolean', + NUMBER = 'number', + OBJECT = 'object', +} + +// Map all non string storage ids to their respective types +export const mapStorageTypeToIds = (id: STORAGE_IDS): STORAGE_TYPES => { + switch (id) { + case STORAGE_IDS.NOTIFICATIONS: + case STORAGE_IDS.GLOBAL_PUSH_NOTIFICATION_SETTINGS: + case STORAGE_IDS.SAFE_FCM_TOKEN: + case STORAGE_IDS.NOTIFICATIONS_SETTINGS: + case STORAGE_IDS.PN_USER_STORAGE: + return STORAGE_TYPES.OBJECT + case STORAGE_IDS.PUSH_NOTIFICATIONS_PROMPT_COUNT: + return STORAGE_TYPES.NUMBER + case STORAGE_IDS.REQUEST_PERMISSION_ASKED: + case STORAGE_IDS.REQUEST_PERMISSION_GRANTED: + return STORAGE_TYPES.BOOLEAN + default: + return STORAGE_TYPES.STRING + } +} + +export type HandleNotificationCallback = (data: FirebaseMessagingTypes.RemoteMessage['data'] | undefined) => void + +export enum PressActionId { + OPEN_NOTIFICATIONS_VIEW = 'open-notifications-view-press-action-id', + OPEN_TRANSACTION_VIEW = 'open-transactions-view-press-action-id', +} + +export const LAUNCH_ACTIVITY = 'global.safe.mobileapp.ui.MainActivity' diff --git a/apps/mobile/src/store/hooks/index.ts b/apps/mobile/src/store/hooks/index.ts new file mode 100644 index 000000000..66442f10f --- /dev/null +++ b/apps/mobile/src/store/hooks/index.ts @@ -0,0 +1,7 @@ +import { useDispatch, useSelector } from 'react-redux' +import { AppDispatch, RootState } from '../index' + +// It's recommended to extend the default redux hooks +// https://redux-toolkit.js.org/tutorials/typescript#define-typed-hooks +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector = useSelector.withTypes() diff --git a/apps/mobile/src/store/hooks/storeHooks.test.ts b/apps/mobile/src/store/hooks/storeHooks.test.ts new file mode 100644 index 000000000..8a80d5977 --- /dev/null +++ b/apps/mobile/src/store/hooks/storeHooks.test.ts @@ -0,0 +1,30 @@ +import { renderHook, act } from '@/src/tests/test-utils' +import { useAppSelector, useAppDispatch } from '.' +import { addTx, txHistorySelector } from '../txHistorySlice' +import { mockHistoryPageItem } from '@/src/tests/mocks' +import { TransactionListItemType } from '@safe-global/store/gateway/types' + +const mockHook = () => { + const dispatch = useAppDispatch() + const historyList = useAppSelector(txHistorySelector) + + return { dispatch, historyList } +} + +describe('React Redux Hooks', () => { + it(`should dispatch an action to a slice`, () => { + const { result } = renderHook(() => mockHook()) + + expect(result.current.historyList.results).toHaveLength(0) + + act(() => { + result.current.dispatch( + addTx({ + item: mockHistoryPageItem(TransactionListItemType.TRANSACTION), + }), + ) + }) + + expect(result.current.historyList.results).toHaveLength(1) + }) +}) diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts new file mode 100644 index 000000000..0535bf24a --- /dev/null +++ b/apps/mobile/src/store/index.ts @@ -0,0 +1,57 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist' +import { reduxStorage } from './storage' +import txHistory from './txHistorySlice' +import activeSafe from './activeSafeSlice' +import signers from './signersSlice' +import myAccounts from './myAccountsSlice' +import notifications from './notificationsSlice' +import safes from './safesSlice' +import { cgwClient, setBaseUrl } from '@safe-global/store/gateway/cgwClient' +import devToolsEnhancer from 'redux-devtools-expo-dev-plugin' +import { GATEWAY_URL, isTestingEnv } from '../config/constants' + +setBaseUrl(GATEWAY_URL) +const persistConfig = { + key: 'root', + version: 1, + storage: reduxStorage, + blacklist: [cgwClient.reducerPath, 'myAccounts'], +} +export const rootReducer = combineReducers({ + txHistory, + safes, + activeSafe, + notifications, + myAccounts, + signers, + [cgwClient.reducerPath]: cgwClient.reducer, +}) + +const persistedReducer = persistReducer(persistConfig, rootReducer) + +export const makeStore = () => + configureStore({ + reducer: persistedReducer, + devTools: false, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }).concat(cgwClient.middleware), + enhancers: (getDefaultEnhancers) => { + if (isTestingEnv) { + return getDefaultEnhancers() + } + + return getDefaultEnhancers().concat(devToolsEnhancer()) + }, + }) + +export const store = makeStore() + +export const persistor = persistStore(store) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch diff --git a/apps/mobile/src/store/myAccountsSlice.ts b/apps/mobile/src/store/myAccountsSlice.ts new file mode 100644 index 000000000..53de4ffa0 --- /dev/null +++ b/apps/mobile/src/store/myAccountsSlice.ts @@ -0,0 +1,22 @@ +import { createSlice } from '@reduxjs/toolkit' +import { RootState } from '.' + +const initialState = { + isEdit: false, +} + +const myAccountsSlice = createSlice({ + name: 'myAccounts', + initialState, + reducers: { + toggleMode: (state) => { + state.isEdit = !state.isEdit + }, + }, +}) + +export const { toggleMode } = myAccountsSlice.actions + +export const selectMyAccountsMode = (state: RootState) => state.myAccounts.isEdit + +export default myAccountsSlice.reducer diff --git a/apps/mobile/src/store/notificationsSlice.ts b/apps/mobile/src/store/notificationsSlice.ts new file mode 100644 index 000000000..b9564ea0b --- /dev/null +++ b/apps/mobile/src/store/notificationsSlice.ts @@ -0,0 +1,57 @@ +import { createSlice } from '@reduxjs/toolkit' +import { RootState } from '.' + +const initialState = { + isDeviceNotificationsEnabled: false, + isAppNotificationsEnabled: false, + fcmToken: null, + remoteMessages: [], + promptAttempts: 0, + lastTimePromptAttempted: null, +} + +const notificationsSlice = createSlice({ + name: 'notifications', + initialState, + reducers: { + toggleAppNotifications: (state, action) => { + state.isAppNotificationsEnabled = action.payload + }, + toggleDeviceNotifications: (state, action) => { + state.isDeviceNotificationsEnabled = action.payload + }, + savePushToken: (state, action) => { + state.fcmToken = action.payload + }, + updateRemoteMessages: (state, action) => { + state.remoteMessages = action.payload + }, + updatePromptAttempts: (state, action) => { + if (action.payload === 0) { + state.promptAttempts = 0 + } + state.promptAttempts += 1 + }, + updateLastTimePromptAttempted: (state, action) => { + state.lastTimePromptAttempted = action.payload + }, + }, +}) + +export const { + toggleAppNotifications, + toggleDeviceNotifications, + savePushToken, + updateRemoteMessages, + updatePromptAttempts, + updateLastTimePromptAttempted, +} = notificationsSlice.actions + +export const selectAppNotificationStatus = (state: RootState) => state.notifications.isAppNotificationsEnabled +export const selectDeviceNotificationStatus = (state: RootState) => state.notifications.isDeviceNotificationsEnabled +export const selectFCMToken = (state: RootState) => state.notifications.fcmToken +export const selectRemoteMessages = (state: RootState) => state.notifications.remoteMessages +export const selectPromptAttempts = (state: RootState) => state.notifications.promptAttempts +export const selectLastTimePromptAttempted = (state: RootState) => state.notifications.lastTimePromptAttempted + +export default notificationsSlice.reducer diff --git a/apps/mobile/src/store/safesSlice.ts b/apps/mobile/src/store/safesSlice.ts new file mode 100644 index 000000000..4e62999aa --- /dev/null +++ b/apps/mobile/src/store/safesSlice.ts @@ -0,0 +1,61 @@ +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' + +export type SafesSliceItem = { + SafeInfo: SafeOverview + chains: string[] +} + +export type SafesSlice = Record + +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 activeSafeSlice = createSlice({ + name: 'safes', + initialState, + reducers: { + updateSafeInfo: (state, action: PayloadAction<{ address: Address; item: SafesSliceItem }>) => { + state[action.payload.address] = action.payload.item + return state + }, + setSafes: (_state, action: PayloadAction>) => { + return action.payload + }, + removeSafe: (state, action: PayloadAction
) => { + 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 + }, + }, +}) + +export const { updateSafeInfo, setSafes, removeSafe } = activeSafeSlice.actions + +export const selectAllSafes = (state: RootState) => state.safes +export const selectSafeInfo = createSelector( + [selectAllSafes, (_state, activeSafeAddress: Address) => activeSafeAddress], + (safes: SafesSlice, activeSafeAddress: Address) => safes[activeSafeAddress], +) + +export default activeSafeSlice.reducer diff --git a/apps/mobile/src/store/signersSlice.ts b/apps/mobile/src/store/signersSlice.ts new file mode 100644 index 000000000..f3a6e316d --- /dev/null +++ b/apps/mobile/src/store/signersSlice.ts @@ -0,0 +1,24 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +import { RootState } from '.' + +const initialState: Record = {} + +const signersSlice = createSlice({ + name: 'signers', + initialState, + reducers: { + addSigner: (state, action: PayloadAction) => { + state[action.payload.value] = action.payload + + return state + }, + }, +}) + +export const { addSigner } = signersSlice.actions + +export const selectSigners = (state: RootState) => state.signers + +export default signersSlice.reducer diff --git a/apps/mobile/src/store/storage.ts b/apps/mobile/src/store/storage.ts new file mode 100644 index 000000000..46355f55c --- /dev/null +++ b/apps/mobile/src/store/storage.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-extraneous-class */ +import { Storage } from 'redux-persist' +import { MMKV } from 'react-native-mmkv' +import { mapStorageTypeToIds, STORAGE_IDS, STORAGE_TYPES } from './constants' + +const safeStorage = new MMKV({ + id: STORAGE_IDS.SAFE, +}) + +export const reduxStorage: Storage = { + setItem: (key, value) => { + safeStorage.set(key, value) + return Promise.resolve(true) + }, + getItem: (key) => { + const value = safeStorage.getString(key) + return Promise.resolve(value) + }, + removeItem: (key) => { + safeStorage.delete(key) + return Promise.resolve() + }, +} + +export class safeMMKVStorage { + static getLocal(key: STORAGE_IDS) { + if (!key) { + return + } + + const keyType = mapStorageTypeToIds(key) + + switch (keyType) { + case STORAGE_TYPES.STRING: + return safeStorage.getString(key) + case STORAGE_TYPES.NUMBER: + return safeStorage.getNumber(key) + case STORAGE_TYPES.BOOLEAN: + return safeStorage.getBoolean(key) + case STORAGE_TYPES.OBJECT: + return JSON.parse(safeStorage.getString(key) || '{}') + default: + return safeStorage.getString(key) + } + } + + static saveLocal(key: string, value: string | number | boolean | ArrayBuffer) { + if (!key) { + return + } + const valueType = typeof value + + if (valueType === 'object') { + return safeStorage.set(key, JSON.stringify(value)) + } + + return safeStorage.set(key, value) + } + + static clearAllStorages() { + Object.keys(STORAGE_IDS).forEach((id) => { + const storage = new MMKV({ id }) + storage.clearAll() + }) + + const defaultStorage = new MMKV() + defaultStorage.clearAll() + } +} diff --git a/apps/mobile/src/store/txHistorySlice.ts b/apps/mobile/src/store/txHistorySlice.ts new file mode 100644 index 000000000..f64b20408 --- /dev/null +++ b/apps/mobile/src/store/txHistorySlice.ts @@ -0,0 +1,23 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '.' +import { TransactionItemPage } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const initialState: TransactionItemPage = { results: [] } + +const txHistorySlice = createSlice({ + name: 'txHistory', + initialState, + reducers: { + // TODO: this will be removed in the next task + // it is here just to test the action + addTx: (state, action: PayloadAction<{ item: TransactionItemPage['results'][number] }>) => { + state.results.push(action.payload.item) + }, + }, +}) + +export const { addTx } = txHistorySlice.actions + +export const txHistorySelector = (state: RootState) => state.txHistory + +export default txHistorySlice.reducer diff --git a/apps/mobile/src/tests/jest.setup.tsx b/apps/mobile/src/tests/jest.setup.tsx new file mode 100644 index 000000000..6fb4a0556 --- /dev/null +++ b/apps/mobile/src/tests/jest.setup.tsx @@ -0,0 +1,193 @@ +import React from 'react' + +import '@testing-library/react-native/extend-expect' +import mockRNDeviceInfo from 'react-native-device-info/jest/react-native-device-info-mock' + +import { server } from './server' + +jest.useFakeTimers() + +/** + * This mock is necessary because useFonts is async and we get an error + * Warning: An update to FontProvider inside a test was not wrapped in act(...) + */ +jest.mock('expo-font', () => ({ + useFonts: () => [true], + isLoaded: () => true, +})) + +jest.mock('react-native-mmkv', () => ({ + MMKV: function () { + // @ts-ignore + this.getString = jest.fn() + // @ts-ignore + this.delete = jest.fn() + // @ts-ignore + this.set = jest.fn() + }, +})) + +jest.mock('react-native-device-info', () => mockRNDeviceInfo) +jest.mock('react-native-device-crypto', () => { + return { + getOrCreateAsymmetricKey: jest.fn(), + encrypt: jest.fn((_asymmetricKey: string, privateKey: string) => { + return Promise.resolve({ + encryptedText: 'encryptedText', + iv: privateKey + '000', + }) + }), + decrypt: jest.fn((_name, _password, iv) => Promise.resolve(iv.slice(0, -3))), + } +}) + +jest.mock('react-native-keychain', () => { + let password: string | null = null + return { + setGenericPassword: jest.fn((_user, newPassword: string) => { + password = newPassword + + return Promise.resolve(password) + }), + getGenericPassword: jest.fn(() => + Promise.resolve({ + password, + }), + ), + resetGenericPassword: jest.fn(() => { + password = null + Promise.resolve(null) + }), + } +}) + +jest.mock('expo-splash-screen', () => ({ + preventAutoHideAsync: jest.fn(), + setOptions: jest.fn(), + hideAsync: jest.fn(), +})) + +jest.mock('redux-persist', () => { + const real = jest.requireActual('redux-persist') + return { + ...real, + persistReducer: jest.fn().mockImplementation((config, reducers) => reducers), + } +}) +jest.mock('redux-devtools-expo-dev-plugin', () => ({ + default: () => jest.fn(), +})) + +jest.mock('@react-native-firebase/messaging', () => { + const module = () => { + return { + getToken: jest.fn(() => Promise.resolve('fcmToken')), + deleteToken: jest.fn(() => Promise.resolve()), + subscribeToTopic: jest.fn(), + unsubscribeFromTopic: jest.fn(), + hasPermission: jest.fn(() => Promise.resolve(module.AuthorizationStatus.AUTHORIZED)), + requestPermission: jest.fn(() => Promise.resolve(module.AuthorizationStatus.AUTHORIZED)), + setBackgroundMessageHandler: jest.fn(() => Promise.resolve()), + isDeviceRegisteredForRemoteMessages: jest.fn(() => Promise.resolve(false)), + registerDeviceForRemoteMessages: jest.fn(() => Promise.resolve('registered')), + unregisterDeviceForRemoteMessages: jest.fn(() => Promise.resolve('unregistered')), + onMessage: jest.fn(), + onTokenRefresh: jest.fn(), + } + } + + module.AuthorizationStatus = { + NOT_DETERMINED: -1, + DENIED: 0, + AUTHORIZED: 1, + PROVISIONAL: 2, + } + + return module +}) + +jest.mock('@notifee/react-native', () => { + const notifee = { + getInitialNotification: jest.fn().mockResolvedValue(null), + displayNotification: jest.fn().mockResolvedValue({}), + onForegroundEvent: jest.fn().mockReturnValue(jest.fn()), + onBackgroundEvent: jest.fn(), + createChannelGroup: jest.fn().mockResolvedValue('channel-group-id'), + createChannel: jest.fn().mockResolvedValue({}), + } + + return { + ...jest.requireActual('@notifee/react-native/dist/types/Notification'), + __esModule: true, + default: notifee, + AndroidImportance: { + NONE: 0, + MIN: 1, + LOW: 2, + DEFAULT: 3, + HIGH: 4, + }, + } +}) + +jest.mock('@gorhom/bottom-sheet', () => { + const reactNative = jest.requireActual('react-native') + const { useState, forwardRef, useImperativeHandle } = jest.requireActual('react') + const { View } = reactNative + const MockBottomSheetComponent = forwardRef( + ( + { + children, + backdropComponent: Backdrop, + backgroundComponent: Background, + }: { backgroundComponent: React.FC; backdropComponent: React.FC; children: React.ReactNode }, + ref: React.ForwardedRef, + ) => { + const [isOpened, setIsOpened] = useState() + + // Exposing some imperative methods to the parent. + useImperativeHandle(ref, () => ({ + // Add methods here that can be accessed using the ref from parent + present: () => { + setIsOpened(true) + }, + dismiss: () => { + setIsOpened(false) + }, + })) + + return isOpened ? ( + <> + + {children} + + ) : null + }, + ) + + MockBottomSheetComponent.displayName = 'MockBottomSheetComponent' + + return { + __esModule: true, + default: View, + BottomSheetFooter: View, + BottomSheetFooterContainer: View, + BottomSheetModal: MockBottomSheetComponent, + BottomSheetModalProvider: View, + BottomSheetView: View, + useBottomSheetModal: () => ({ + dismiss: () => { + return null + }, + }), + } +}) + +jest.mock('@react-native-clipboard/clipboard', () => ({ + setString: jest.fn(), + getString: jest.fn(), +})) + +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 new file mode 100644 index 000000000..7a3aa4201 --- /dev/null +++ b/apps/mobile/src/tests/mocks.ts @@ -0,0 +1,265 @@ +import { + PendingTransactionItems, + DetailedExecutionInfoType, + TransactionTokenType, + TransactionStatus, + TransactionInfoType, + TransferDirection, + ConflictType, + TransactionListItemType, + HistoryTransactionItems, +} from '@safe-global/store/gateway/types' +import { + TransferTransactionInfo, + SwapTransferTransactionInfo, + DateLabel, + TransactionQueuedItem, + LabelQueuedItem, + ConflictHeaderQueuedItem, + AddressInfo, + Transaction, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +export const mockBalanceData = { + items: [ + { + tokenInfo: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + }, + balance: '1000000000000000000', + fiatBalance: '2000', + }, + ], +} + +export const mockNFTData = { + count: 2, + next: null, + previous: null, + results: [ + { + id: '1', + address: '0x123', + tokenName: 'Cool NFT', + tokenSymbol: 'CNFT', + logoUri: 'https://example.com/nft1.png', + name: 'NFT #1', + description: 'A cool NFT', + tokenId: '1', + uri: 'https://example.com/nft1.json', + imageUri: 'https://example.com/nft1.png', + }, + { + id: '2', + address: '0x456', + tokenName: 'Another NFT', + tokenSymbol: 'ANFT', + logoUri: 'https://example.com/nft2.png', + name: 'NFT #2', + description: 'Another cool NFT', + tokenId: '2', + uri: 'https://example.com/nft2.json', + imageUri: 'https://example.com/nft2.png', + }, + ], +} +export const fakeToken = { + address: '0x1111111111', + decimals: 18, + name: 'Ether', + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + symbol: 'ETH', + trusted: false, +} +export const fakeToken2 = { + address: '0x1111111111', + decimals: 18, + name: 'SafeToken', + logoUri: 'https://safe-transaction-assets.safe.global/tokens/logos/0x5aFE3855358E112B5647B952709E6165e1c1eEEe.png', + symbol: 'SAFE', + trusted: false, +} +export const mockERC20Transfer: TransferTransactionInfo = { + type: TransactionInfoType.TRANSFER, + sender: { + value: '0x000000', + name: 'something', + }, + recipient: { + value: '0x0ab', + name: 'something', + }, + transferInfo: { + type: TransactionTokenType.ERC20, + tokenAddress: '0x000000', + value: '50000000000000000', + tokenName: 'Nevinha', + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + tokenSymbol: 'NEV', + trusted: false, + decimals: 18, + imitation: true, + }, + direction: TransferDirection.INCOMING, + humanDescription: 'a simple incoming transaction', +} +export const mockNFTTransfer: TransferTransactionInfo = { + type: TransactionInfoType.TRANSFER, + sender: { + value: '0x000000', + name: 'something', + }, + recipient: { + value: '0x0ab', + name: 'something', + }, + transferInfo: { + tokenId: '1', + type: TransactionTokenType.ERC721, + tokenAddress: '0x000000', + tokenName: 'My NFT', + tokenSymbol: 'NEV', + }, + direction: TransferDirection.OUTGOING, + humanDescription: 'a simple incoming transaction', +} +export const mockSwapTransfer: SwapTransferTransactionInfo = { + type: TransactionInfoType.SWAP_TRANSFER, + 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: '231', + humanDescription: 'here a human description', + status: 'fulfilled', + kind: 'buy', + orderClass: 'limit', + validUntil: 11902381293, + sellAmount: '50000000000000000', + buyAmount: '50000000000000000', + executedSellAmount: '50000000000000000', + executedBuyAmount: '50000000000000000', + executedFee: '1000000000000000', + executedFeeToken: fakeToken, + sellToken: fakeToken2, + buyToken: fakeToken, + explorerUrl: 'http://google.com', + executedSurplusFee: '', + receiver: '0xbob', + owner: '0xalice', +} + +interface mockTransferWithInfoArgs { + type?: TransactionInfoType + direction?: TransferDirection + methodName?: string + actionCount?: number + isCancellation?: boolean + to?: AddressInfo + creator?: AddressInfo +} + +export const mockTransferWithInfo = ({ + type = TransactionInfoType.TRANSFER, + direction = TransferDirection.INCOMING, + methodName, + actionCount, + isCancellation, + to, + creator, +}: mockTransferWithInfoArgs): Transaction['txInfo'] => + ({ + type, + sender: { + value: '0x000000', + name: 'something', + }, + to, + creator, + methodName, + actionCount, + recipient: { + value: '0x0ab', + name: 'something', + }, + transferInfo: { + type: TransactionTokenType.ERC20, + tokenAddress: '0x000000', + value: '100000', + trusted: false, + imitation: true, + }, + dataDecoded: { + method: 'mockMethod', + }, + isCancellation, + direction, + humanDescription: 'a simple incoming transaction', + }) as Transaction['txInfo'] + +export const mockTransactionSummary: Transaction = { + id: 'id', + timestamp: 123123, + txStatus: TransactionStatus.SUCCESS, + txInfo: mockTransferWithInfo({ type: TransactionInfoType.TRANSFER }), + txHash: '0x0000000', + executionInfo: { + type: DetailedExecutionInfoType.MODULE, + address: { + value: '0x000000', + name: 'something', + }, + }, +} + +export const mockHistoryPageItem = (type: 'TRANSACTION'): HistoryTransactionItems => { + return { + type, + transaction: mockTransactionSummary, + conflictType: ConflictType.NONE, + } +} + +export const mockListItemByType = (type: TransactionListItemType): PendingTransactionItems | DateLabel => { + if (type === TransactionListItemType.DATE_LABEL) { + return { + type: TransactionListItemType.DATE_LABEL, + timestamp: 123123, + } as DateLabel + } + + if (type === TransactionListItemType.LABEL) { + return { + type: TransactionListItemType.LABEL, + label: 'label', + } as LabelQueuedItem + } + + if (type === TransactionListItemType.CONFLICT_HEADER) { + return { + type: TransactionListItemType.CONFLICT_HEADER, + nonce: 123, + } as ConflictHeaderQueuedItem + } + + return { + type: TransactionListItemType.TRANSACTION, + transaction: mockTransactionSummary, + conflictType: ConflictType.NONE, + } as TransactionQueuedItem +} diff --git a/apps/mobile/src/tests/server.ts b/apps/mobile/src/tests/server.ts new file mode 100644 index 000000000..809d23562 --- /dev/null +++ b/apps/mobile/src/tests/server.ts @@ -0,0 +1,5 @@ +import { setupServer } from 'msw/node' +import { handlers } from '@safe-global/test/msw/handlers' +import { GATEWAY_URL } from '@/src/config/constants' + +export const server = setupServer(...handlers(GATEWAY_URL)) diff --git a/apps/mobile/src/tests/test-utils.tsx b/apps/mobile/src/tests/test-utils.tsx new file mode 100644 index 000000000..806c6bc1d --- /dev/null +++ b/apps/mobile/src/tests/test-utils.tsx @@ -0,0 +1,62 @@ +import { render as nativeRender, renderHook } from '@testing-library/react-native' +import { SafeThemeProvider } from '@/src/theme/provider/safeTheme' +import { Provider } from 'react-redux' +import { makeStore, rootReducer } from '../store' +import { PortalProvider } from 'tamagui' +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' +import { configureStore } from '@reduxjs/toolkit' + +export type RootState = ReturnType +type getProvidersArgs = (initialStoreState?: Partial) => React.FC<{ children: React.ReactNode }> + +const getProviders: getProvidersArgs = (initialStoreState) => + function ProviderComponent({ children }: { children: React.ReactNode }) { + const store = initialStoreState + ? configureStore({ + reducer: rootReducer, + preloadedState: initialStoreState, + }) + : makeStore() + + return ( + + + + {children} + + + + ) + } + +const customRender = ( + ui: React.ReactElement, + { + initialStore, + wrapper: CustomWrapper, + }: { + initialStore?: Partial + wrapper?: React.ComponentType<{ children: React.ReactNode }> + } = {}, +) => { + const Wrapper = getProviders(initialStore) + + function WrapperWithCustom({ children }: { children: React.ReactNode }) { + return {CustomWrapper ? {children} : children} + } + + return nativeRender(ui, { wrapper: WrapperWithCustom }) +} + +function customRenderHook(render: (initialProps: Props) => Result) { + const wrapper = getProviders() + + return renderHook(render, { wrapper }) +} + +// re-export everything +export * from '@testing-library/react-native' + +// override render method +export { customRender as render } +export { customRenderHook as renderHook } diff --git a/apps/mobile/src/theme/helpers/utils.ts b/apps/mobile/src/theme/helpers/utils.ts new file mode 100644 index 000000000..e7e6404e8 --- /dev/null +++ b/apps/mobile/src/theme/helpers/utils.ts @@ -0,0 +1,61 @@ +interface Palette { + [key: string]: string | Palette +} + +type Flatten< + T extends Palette, + Prefix extends string = '', + Suffix extends string = '', + Depth extends number = 5, // Limit recursion depth +> = [Depth] extends [never] + ? object + : T extends object + ? { + [K in keyof T as T[K] extends object + ? never + : Prefix extends '' + ? `${K & string}${Suffix}` + : `${Prefix}${Capitalize}${Suffix}`]: T[K] + } & UnionToIntersection< + { + [K in keyof T]: T[K] extends object + ? Flatten}`, Suffix, Prev[Depth]> + : object + }[keyof T] + > + : object + +type Prev = [never, 0, 1, 2, 3, 4, 5] + +type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never + +export function flattenPalette( + palette: T, + options?: { suffix?: Suffix }, +): Flatten { + const result = {} as Flatten + const suffix = (options?.suffix ?? '') as Suffix + + function flatten(current: Palette, parentKey = ''): void { + for (const key in current) { + if (Object.prototype.hasOwnProperty.call(current, key)) { + const value = current[key] + + const newKey = parentKey ? parentKey + key.charAt(0).toUpperCase() + key.slice(1) : key + + if (typeof value === 'object' && value !== null) { + flatten(value as Palette, newKey) + } else { + ;(result as Flatten)[(newKey + suffix) as keyof Flatten] = value as Flatten< + T, + '', + Suffix + >[keyof Flatten] + } + } + } + } + + flatten(palette) + return result +} diff --git a/apps/mobile/src/theme/navigation.ts b/apps/mobile/src/theme/navigation.ts new file mode 100644 index 000000000..b24f8944e --- /dev/null +++ b/apps/mobile/src/theme/navigation.ts @@ -0,0 +1,28 @@ +import { getTokenValue } from 'tamagui' +import type { Theme } from '@react-navigation/native/src/types' +import { DarkTheme, DefaultTheme } from '@react-navigation/native' +export const NavDarkTheme: Theme = { + ...DarkTheme, + dark: true, + colors: { + primary: getTokenValue('$color.primaryMainDark'), + background: getTokenValue('$color.backgroundMainDark'), + card: getTokenValue('$color.backgroundMainDark'), + text: getTokenValue('$color.textPrimaryDark'), + border: getTokenValue('$color.backgroundMainDark'), + notification: getTokenValue('$color.warningBackgroundDark'), + }, +} + +export const NavLightTheme: Theme = { + ...DefaultTheme, + dark: false, + colors: { + primary: getTokenValue('$color.primaryMainLight'), + background: getTokenValue('$color.backgroundMainLight'), + card: getTokenValue('$color.backgroundMainLight'), + text: getTokenValue('$color.textPrimaryLight'), + border: getTokenValue('$color.backgroundMainLight'), + notification: getTokenValue('$color.warningBackgroundLight'), + }, +} diff --git a/apps/mobile/src/theme/palettes/darkPalette.ts b/apps/mobile/src/theme/palettes/darkPalette.ts new file mode 100644 index 000000000..565d44ec9 --- /dev/null +++ b/apps/mobile/src/theme/palettes/darkPalette.ts @@ -0,0 +1,74 @@ +const darkPalette = { + text: { + primary: '#FFFFFF', + secondary: '#636669', + disabled: '#636669', + }, + primary: { + dark: '#0cb259', + main: '#12FF80', + light: '#A1A3A7', + }, + secondary: { + dark: '#636669', + main: '#FFFFFF', + light: '#B0FFC9', + background: '#1B2A22', + }, + border: { + main: '#636669', + light: '#303033', + background: '#121312', + }, + error: { + dark: '#411C20', + main: '#FF5F72', + light: '#FFB4BD', + background: '#2F2527', + }, + error1: { + main: '#49191F', + contrastText: '#FF5F72', + }, + success: { + dark: '#1D3D28', + main: '#00B460', + light: '#81C784', + background: '#1F2920', + }, + info: { + dark: '#52BFDC', + main: '#5FDDFF', + light: '#B7F0FF', + background: '#19252C', + }, + warning: { + dark: '#432F18', + main: '#FF8061', + light: '#FFBC9F', + background: '#2F2318', + }, + warning1: { + main: '#322211', + contrastText: '#FF8C00', + }, + background: { + default: '#121312', + main: '#121312', + paper: '#1C1C1C', + light: '#1B2A22', + skeleton: 'rgba(255, 255, 255, 0.04)', + }, + backdrop: { + main: '#636669', + }, + logo: { + main: '#FFFFFF', + background: '#303033', + }, + static: { + main: '#121312', + }, +} + +export default darkPalette diff --git a/apps/mobile/src/theme/palettes/lightPalette.ts b/apps/mobile/src/theme/palettes/lightPalette.ts new file mode 100644 index 000000000..a8f1140c6 --- /dev/null +++ b/apps/mobile/src/theme/palettes/lightPalette.ts @@ -0,0 +1,74 @@ +const lightPalette = { + text: { + primary: '#121312', + secondary: '#A1A3A7', + disabled: '#DDDEE0', + }, + primary: { + dark: '#3c3c3c', + main: '#121312', + light: '#636669', + }, + secondary: { + dark: '#0FDA6D', + main: '#12FF80', + light: '#B0FFC9', + background: '#EFFFF4', + }, + border: { + main: '#A1A3A7', + light: '#DCDEE0', + background: '#F4F4F4', + }, + error: { + dark: '#411C20', + main: '#FF5F72', + light: '#FFB4BD', + background: '#FFE6EA', + }, + error1: { + main: '#49191F', + contrastText: '#FF5F72', + }, + success: { + dark: '#2D3D28', + main: '#00B460', + light: '#72F5B8', + background: '#EFFAF1', + }, + info: { + dark: '#553B1E', + main: '#5FDDFF', + light: '#B7F0FF', + background: '#EFFCFF', + }, + warning: { + dark: '#C04C32', + main: '#FF8061', + light: '#FFBC9F', + background: '#FFF1E0', + }, + warning1: { + main: '#fff0e0', + contrastText: '#FF8C00', + }, + background: { + default: '#FFFFFF', + main: '#FFFFFF', + paper: '#F4F4F4', + light: '#EFFFF4', + skeleton: 'rgba(255, 255, 255, 0.04)', + }, + backdrop: { + main: '#636669', + }, + logo: { + main: '#121312', + background: '#EEEFF0', + }, + static: { + main: '#121312', + }, +} + +export default lightPalette diff --git a/apps/mobile/src/theme/provider/font.tsx b/apps/mobile/src/theme/provider/font.tsx new file mode 100644 index 000000000..20210d7c4 --- /dev/null +++ b/apps/mobile/src/theme/provider/font.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } 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 * as SplashScreen from 'expo-splash-screen' + +interface SafeThemeProviderProps { + children: React.ReactNode +} + +// Prevent the splash screen from auto-hiding before asset loading is complete. +SplashScreen.preventAutoHideAsync() + +SplashScreen.setOptions({ + duration: 1000, + fade: true, +}) + +export const FontProvider = ({ children }: SafeThemeProviderProps) => { + const [loaded] = useFonts({ + 'DmSans-SemiBold': DmSansSemiBold, + 'DmSans-Regular': DmSansRegular, + 'DmSans-Medium': DmSansMedium, + }) + + useEffect(() => { + if (loaded) { + SplashScreen.hideAsync() + } + }, [loaded]) + + if (!loaded) { + return null + } + + return children +} diff --git a/apps/mobile/src/theme/provider/safeTheme.tsx b/apps/mobile/src/theme/provider/safeTheme.tsx new file mode 100644 index 000000000..7340b83e6 --- /dev/null +++ b/apps/mobile/src/theme/provider/safeTheme.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { StatusBar, useColorScheme } from 'react-native' +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 { isStorybookEnv } from '@/src/config/constants' +import { View } from 'tamagui' + +interface SafeThemeProviderProps { + children: React.ReactNode +} + +export const SafeThemeProvider = ({ children }: SafeThemeProviderProps) => { + const colorScheme = useColorScheme() + + const themeProvider = isStorybookEnv ? ( + + {children} + + ) : ( + {children} + ) + + return ( + + + + + {themeProvider} + + + ) +} diff --git a/apps/mobile/src/theme/provider/toastProvider.tsx b/apps/mobile/src/theme/provider/toastProvider.tsx new file mode 100644 index 000000000..acde0f2fb --- /dev/null +++ b/apps/mobile/src/theme/provider/toastProvider.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { Toast, ToastProvider, ToastViewport, useToastState } from '@tamagui/toast' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { YStack } from 'tamagui' + +interface SafeThemeProviderProps { + children: React.ReactNode +} + +export const SafeToastProvider = ({ children }: SafeThemeProviderProps) => { + const { top } = useSafeAreaInsets() + + return ( + + {children} + + + + ) +} + +const CurrentToast = () => { + const currentToast = useToastState() + + if (!currentToast || currentToast.isHandledNatively) { + return null + } + + return ( + + + {currentToast.title} + {!!currentToast.message && {currentToast.message}} + + + ) +} diff --git a/apps/mobile/src/theme/tamagui.config.ts b/apps/mobile/src/theme/tamagui.config.ts new file mode 100644 index 000000000..9212b91d4 --- /dev/null +++ b/apps/mobile/src/theme/tamagui.config.ts @@ -0,0 +1,175 @@ +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 { createAnimations } from '@tamagui/animations-moti' +import { inputTheme } from '../components/SafeInput/theme' + +const DmSansFont = createDmSansFont({ + face: { + 500: { normal: 'DMSans-Medium', italic: 'DMSans-MediumItalic' }, + 600: { normal: 'DMSans-SemiBold', italic: 'DMSans-SemiBoldItalic' }, + 700: { normal: 'DMSans-Bold', italic: 'DMSans-BoldItalic' }, + }, +}) +export const config = createTamagui({ + fonts: { + body: DmSansFont, + heading: DmSansFont, + }, + themes: { + light: { + background: tokens.color.backgroundMainLight, + backgroundPaper: tokens.color.backgroundPaperLight, + backgroundHover: tokens.color.backgroundLightLight, + backgroundPress: tokens.color.primaryLightLight, + backgroundFocus: tokens.color.backgroundMainLight, + backgroundStrong: tokens.color.primaryDarkLight, + backgroundTransparent: 'transparent', + backgroundSkeleton: tokens.color.backgroundSkeletonLight, + color: tokens.color.textPrimaryLight, + primary: tokens.color.primaryMainLight, + colorHover: tokens.color.textSecondaryLight, + colorSecondary: tokens.color.textSecondaryLight, + borderLight: tokens.color.borderLightLight, + error: tokens.color.errorMainLight, + errorDark: tokens.color.errorDarkDark, + }, + light_label: { + color: tokens.color.textSecondaryLight, + }, + 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, + ...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, + }, + 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, + }, + dark_logo: { + background: tokens.color.logoBackgroundDark, + }, + light_container: { + background: tokens.color.backgroundPaperLight, + }, + light_safe_list: { + background: tokens.color.backgroundDefaultLight, + }, + dark_safe_list: { + background: tokens.color.backgroundDefaultDark, + }, + dark: { + background: tokens.color.backgroundDefaultDark, + 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, + color: tokens.color.textPrimaryDark, + primary: tokens.color.primaryMainDark, + borderLight: tokens.color.borderLightDark, + colorHover: tokens.color.textSecondaryDark, + colorSecondary: tokens.color.textSecondaryDark, + error: tokens.color.errorMainDark, + errorDark: tokens.color.errorDarkDark, + }, + }, + tokens, + animations: createAnimations({ + fast: { + type: 'spring', + damping: 20, + mass: 1.2, + stiffness: 250, + }, + medium: { + type: 'spring', + damping: 10, + mass: 0.9, + stiffness: 100, + }, + slow: { + type: 'spring', + damping: 20, + stiffness: 60, + }, + '100ms': { + type: 'timing', + duration: 100, + }, + '200ms': { + type: 'timing', + duration: 200, + }, + bouncy: { + type: 'spring', + damping: 10, + mass: 0.9, + stiffness: 100, + }, + lazy: { + type: 'spring', + damping: 20, + stiffness: 60, + }, + quick: { + type: 'spring', + damping: 20, + mass: 1.2, + stiffness: 250, + }, + tooltip: { + damping: 10, + mass: 0.9, + stiffness: 100, + }, + }), +}) + +export type Conf = typeof config + +declare module 'tamagui' { + interface TamaguiCustomConfig extends Conf { + tokens: typeof tokens + } +} + +export default config diff --git a/apps/mobile/src/theme/tokens.ts b/apps/mobile/src/theme/tokens.ts new file mode 100644 index 000000000..548683095 --- /dev/null +++ b/apps/mobile/src/theme/tokens.ts @@ -0,0 +1,41 @@ +import { createTokens } from 'tamagui' +import { radius, 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' +const colors = { + ...flattenPalette(lightPalette, { suffix: 'Light' }), + ...flattenPalette(darkPalette, { suffix: 'Dark' }), +} +export const tokens = createTokens({ + color: colors, + space: { + $1: 4, + $2: 8, + true: 8, + $3: 12, + $4: 16, + $5: 20, + $6: 24, + $7: 28, + $8: 32, + $9: 36, + $10: 40, + }, + // space, + size: { + $1: 4, + $2: 8, + true: 8, + $3: 12, + $4: 16, + $5: 20, + $6: 24, + $7: 28, + $8: 32, + $9: 36, + $10: 40, + }, + zIndex, + radius, +}) diff --git a/apps/mobile/src/types/address.ts b/apps/mobile/src/types/address.ts new file mode 100644 index 000000000..2125eb77f --- /dev/null +++ b/apps/mobile/src/types/address.ts @@ -0,0 +1,6 @@ +export interface SafeInfo { + address: Address + chainId: string +} + +export type Address = `0x${string}` diff --git a/apps/mobile/src/types/iconTypes.ts b/apps/mobile/src/types/iconTypes.ts new file mode 100644 index 000000000..300a733c8 --- /dev/null +++ b/apps/mobile/src/types/iconTypes.ts @@ -0,0 +1,212 @@ +export type IconName = + | 'block' + | 'alert-triangle' + | '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' + | 'arrow-down' + | 'arrow-up' + | 'arrow-left' + | 'arrow-right' + | 'tag' + | 'camera' + | 'element-drag' + | 'transaction-partial-fill' + | 'rows-2' + | 'check-notifications' + | 'qr-code-1' + | 'scan-1' + | 'shield-crossed' + | 'shield' + | 'clock' + | 'update' + | 'repeat' + | 'download' + | 'upload' + | 'qr-code' + | 'scan' + | 'eye-n' + | 'eye-off' + | 'unlock' + | 'lock' + | 'replace-owner' + | 'edit-owner' + | 'add-owner' + | 'send-to' + | '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' + | 'rows-1' + | 'batch' + | 'filter' + | 'bookmark-filled' + | 'bookmark' + | 'transaction-recovery' + | 'transaction-change-settings' + | 'transaction-contract' + | 'transaction-execute' + | 'transaction-stake' + | 'transaction-swap' + | 'transaction-outgoing' + | 'transaction-incoming' + | 'mobile' + | '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', + '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', + 'arrow-down', + 'arrow-up', + 'arrow-left', + 'arrow-right', + 'tag', + 'camera', + 'element-drag', + 'transaction-partial-fill', + 'rows-2', + 'check-notifications', + 'qr-code-1', + 'scan-1', + 'shield-crossed', + 'shield', + 'clock', + 'update', + 'repeat', + 'download', + 'upload', + 'qr-code', + 'scan', + 'eye-n', + 'eye-off', + 'unlock', + 'lock', + 'replace-owner', + 'edit-owner', + 'add-owner', + 'send-to', + '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', + 'rows-1', + 'batch', + 'filter', + 'bookmark-filled', + 'bookmark', + 'transaction-recovery', + 'transaction-change-settings', + 'transaction-contract', + 'transaction-execute', + 'transaction-stake', + 'transaction-swap', + 'transaction-outgoing', + 'transaction-incoming', + 'mobile', + '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/navigation.d.ts b/apps/mobile/src/types/navigation.d.ts new file mode 100644 index 000000000..a35d3214c --- /dev/null +++ b/apps/mobile/src/types/navigation.d.ts @@ -0,0 +1,11 @@ +// navigation.d.ts + +export interface Screens {} + +export declare global { + namespace ReactNavigation { + interface RootParamList extends Screens { + settings: { safeAddress?: string } + } + } +} diff --git a/apps/mobile/src/types/notifee.d.ts b/apps/mobile/src/types/notifee.d.ts new file mode 100644 index 000000000..e399310d5 --- /dev/null +++ b/apps/mobile/src/types/notifee.d.ts @@ -0,0 +1 @@ +declare module '@notifee/react-native/jest-mock' diff --git a/apps/mobile/src/types/react-native-device-info.d.ts b/apps/mobile/src/types/react-native-device-info.d.ts new file mode 100644 index 000000000..f474fd9a6 --- /dev/null +++ b/apps/mobile/src/types/react-native-device-info.d.ts @@ -0,0 +1 @@ +declare module 'react-native-device-info/jest/react-native-device-info-mock' diff --git a/apps/mobile/src/utils/date.test.ts b/apps/mobile/src/utils/date.test.ts new file mode 100644 index 000000000..121de73fc --- /dev/null +++ b/apps/mobile/src/utils/date.test.ts @@ -0,0 +1,43 @@ +import timezoneMock from 'timezone-mock' +import { currentMinutes, formatDateTime, formatTime, getCountdown, getPeriod } from './date' + +const MOCKED_TIMESTAMP = 1729506116962 + +describe('Date utils', () => { + beforeAll(() => { + timezoneMock.register('Etc/GMT-2') + jest.spyOn(Date, 'now').mockImplementation(() => MOCKED_TIMESTAMP) + }) + + it('should show the date in minutes', () => { + expect(currentMinutes()).toBe(28825101) + }) + + it('should format the date time based in a timestamp', () => { + expect(formatTime(MOCKED_TIMESTAMP)).toBe('12:21 PM') + }) + + it('should format the date based in a timestamp', () => { + expect(formatDateTime(MOCKED_TIMESTAMP)).toBe('Oct 21, 2024 - 12:21:56 PM') + }) + + it('should return a countdown object', () => { + expect(getCountdown(20000)).toStrictEqual({ + days: 0, + hours: 5, + minutes: 33, + }) + }) + + it('should get the time period in hours from seconds', () => { + expect(getPeriod(20000)).toBe('5 hours') + }) + + it('should get the time period in minutes from seconds', () => { + expect(getPeriod(2000)).toBe('33 minutes') + }) + + it('should get the time period in days from seconds', () => { + expect(getPeriod(2000000)).toBe('23 days') + }) +}) diff --git a/apps/mobile/src/utils/date.ts b/apps/mobile/src/utils/date.ts new file mode 100644 index 000000000..6b24c8f6d --- /dev/null +++ b/apps/mobile/src/utils/date.ts @@ -0,0 +1,41 @@ +import { format, formatDistanceToNow } from 'date-fns' + +export const currentMinutes = (): number => Math.floor(Date.now() / (1000 * 60)) + +export const formatWithSchema = (timestamp: number, schema: string): string => format(timestamp, schema) + +export const formatTime = (timestamp: number): string => formatWithSchema(timestamp, 'h:mm a') + +export const formatDateTime = (timestamp: number): string => formatWithSchema(timestamp, 'MMM d, yyyy - h:mm:ss a') + +export const formatTimeInWords = (timestamp: number): string => formatDistanceToNow(timestamp, { addSuffix: true }) + +export function getCountdown(seconds: number): { days: number; hours: number; minutes: number } { + const MINUTE_IN_SECONDS = 60 + const HOUR_IN_SECONDS = 60 * MINUTE_IN_SECONDS + const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS + + const days = Math.floor(seconds / DAY_IN_SECONDS) + + const remainingSeconds = seconds % DAY_IN_SECONDS + const hours = Math.floor(remainingSeconds / HOUR_IN_SECONDS) + const minutes = Math.floor((remainingSeconds % HOUR_IN_SECONDS) / MINUTE_IN_SECONDS) + + return { days, hours, minutes } +} + +export function getPeriod(seconds: number): string | undefined { + const { days, hours, minutes } = getCountdown(seconds) + + if (days > 0) { + return `${days} day${days === 1 ? '' : 's'}` + } + + if (hours > 0) { + return `${hours} hour${hours === 1 ? '' : 's'}` + } + + if (minutes > 0) { + return `${minutes} minute${minutes === 1 ? '' : 's'}` + } +} diff --git a/apps/mobile/src/utils/formatters.ts b/apps/mobile/src/utils/formatters.ts new file mode 100644 index 000000000..86db4980a --- /dev/null +++ b/apps/mobile/src/utils/formatters.ts @@ -0,0 +1,17 @@ +export const ellipsis = (str: string, length: number): string => { + return str.length > length ? `${str.slice(0, length)}...` : str +} + +export const makeSafeId = (chainId: string, address: string) => `${chainId}:${address}` as `${number}:0x${string}` + +export const shortenAddress = (address: string, length = 4): string => { + if (!address) { + return '' + } + + return `${address.slice(0, length + 2)}...${address.slice(-length)}` +} + +export const formatValue = (value: string, decimals: number): string => { + return (parseInt(value) / 10 ** decimals).toString().substring(0, 8) +} diff --git a/apps/mobile/src/utils/gateway.test.ts b/apps/mobile/src/utils/gateway.test.ts new file mode 100644 index 000000000..80d2c7e3a --- /dev/null +++ b/apps/mobile/src/utils/gateway.test.ts @@ -0,0 +1,62 @@ +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/gateway.ts b/apps/mobile/src/utils/gateway.ts new file mode 100644 index 000000000..770301365 --- /dev/null +++ b/apps/mobile/src/utils/gateway.ts @@ -0,0 +1,28 @@ +import { type Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +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: Chain['blockExplorerUriTemplate'], +): string => { + const isTx = hash.length > 42 + const param = isTx ? 'txHash' : 'address' + + return _replaceTemplate(blockExplorerUriTemplate[param], { [param]: hash }) +} + +export const getExplorerLink = ( + hash: string, + blockExplorerUriTemplate: Chain['blockExplorerUriTemplate'], +): { href: string; title: string } => { + const href = getHashedExplorerUrl(hash, blockExplorerUriTemplate) + const title = `View on ${new URL(href).hostname}` + + return { href, title } +} diff --git a/apps/mobile/src/utils/logger.ts b/apps/mobile/src/utils/logger.ts new file mode 100644 index 000000000..dd2f5251f --- /dev/null +++ b/apps/mobile/src/utils/logger.ts @@ -0,0 +1,89 @@ +import { format } from 'date-fns' + +export enum LogLevel { + TRACE = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +const style = (color: string, bold = true): string => { + return `color:${color};font-weight:${bold ? '600' : '300'};font-size:11px` +} + +const now = (): string => format(new Date(), '[HH:mm:ss.SS]') + +const formatMessage = ( + message: string, + color?: string, +): [string, string, string, string] | [string, string, string] => { + if (color) { + return ['%c%s %s', style(color), now(), message] + } + + return ['%s %s', now(), message] +} + +class Logger { + private level: LogLevel = LogLevel.ERROR + private shouldLogErrorToSentry = false + + setLevel = (level: LogLevel): void => { + this.level = level + } + + shouldLog = (level: LogLevel): boolean => { + return level >= this.level + } + + trace = (message: string, value?: unknown): void => { + if (this.shouldLog(LogLevel.TRACE)) { + console.groupCollapsed(...formatMessage(message, 'grey')) + if (value) { + console.trace(value) + } + console.groupEnd() + } + } + + info = (message: string, value?: unknown): void => { + if (this.shouldLog(LogLevel.INFO)) { + if (message) { + console.info(...formatMessage(message)) + } + if (value) { + console.info(value) + } + } + } + + warn = (message: string, value?: unknown): void => { + if (this.shouldLog(LogLevel.WARN)) { + console.groupCollapsed(...formatMessage(message, 'yellow')) + if (value) { + console.warn(value) + } + console.groupEnd() + } + } + + error = (message: string, value?: unknown): void => { + if (this.shouldLog(LogLevel.ERROR)) { + console.groupCollapsed(...formatMessage(message, 'red')) + if (value) { + console.error(value) + } + console.groupEnd() + + if (this.shouldLogErrorToSentry) { + // TODO: implement Sentry error logging + } + } + } + + setShouldLogErrorToSentry = (shouldLogErrorToSentry: boolean): void => { + this.shouldLogErrorToSentry = shouldLogErrorToSentry + } +} + +export default new Logger() diff --git a/apps/mobile/src/utils/notifications/index.ts b/apps/mobile/src/utils/notifications/index.ts new file mode 100644 index 000000000..49bcd2f68 --- /dev/null +++ b/apps/mobile/src/utils/notifications/index.ts @@ -0,0 +1,38 @@ +import { AndroidChannel, AndroidImportance } from '@notifee/react-native' + +export enum ChannelId { + DEFAULT_NOTIFICATION_CHANNEL_ID = 'DEFAULT_NOTIFICATION_CHANNEL_ID', + ANNOUNCEMENT_NOTIFICATION_CHANNEL_ID = 'ANNOUNCEMENT_NOTIFICATION_CHANNEL_ID', +} + +export interface SafeAndroidChannel extends AndroidChannel { + id: ChannelId + title: string + subtitle: string +} + +export const notificationChannels = [ + { + id: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, + name: 'Transaction Complete', + lights: true, + vibration: true, + importance: AndroidImportance.HIGH, + title: 'Transaction', + subtitle: 'Transaction Complete', + } as SafeAndroidChannel, + { + id: ChannelId.ANNOUNCEMENT_NOTIFICATION_CHANNEL_ID, + name: 'Safe Announcement', + lights: true, + vibration: true, + importance: AndroidImportance.HIGH, + title: 'Announcement', + subtitle: 'Safe Announcement', + } as SafeAndroidChannel, +] + +export function withTimeout(promise: Promise, ms: number): Promise { + const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms)) + return Promise.race([promise, timeout]) +} diff --git a/apps/mobile/src/utils/transaction-guards.test.ts b/apps/mobile/src/utils/transaction-guards.test.ts new file mode 100644 index 000000000..aab64ad88 --- /dev/null +++ b/apps/mobile/src/utils/transaction-guards.test.ts @@ -0,0 +1,153 @@ +import { + DetailedExecutionInfoType, + ExecutionInfo, + TransactionInfoType, + TransactionListItemType, + TransactionStatus, + TransferDirection, +} from '@safe-global/store/gateway/types' +import { + isCancellationTxInfo, + isCreationTxInfo, + isCustomTxInfo, + isDateLabel, + isLabelListItem, + isModuleExecutionInfo, + isMultiSendTxInfo, + isMultisigExecutionInfo, + isOutgoingTransfer, + isSwapOrderTxInfo, + isTransactionListItem, + isTransferTxInfo, + isTxQueued, +} from './transaction-guards' +import { mockERC20Transfer, mockListItemByType, mockSwapTransfer, mockTransferWithInfo } from '../tests/mocks' + +const multisigTx: ExecutionInfo = { + type: DetailedExecutionInfoType.MULTISIG, + nonce: 1, + confirmationsRequired: 2, + confirmationsSubmitted: 1, + missingSigners: [ + { + value: '0x000000', + name: 'alice', + }, + { + value: '0x00asd0', + name: 'bob', + }, + ], +} + +const moduleTx: ExecutionInfo = { + type: DetailedExecutionInfoType.MODULE, + address: { + value: '0x000000', + name: 'alice', + }, +} + +describe('Transaction Guards', () => { + it('should check if isTxQueued', () => { + expect(isTxQueued(TransactionStatus.AWAITING_CONFIRMATIONS)).toBe(true) + expect(isTxQueued(TransactionStatus.AWAITING_EXECUTION)).toBe(true) + expect(isTxQueued(TransactionStatus.CANCELLED)).toBe(false) + }) + + it('should check a txInfo transfer', () => { + expect(isTransferTxInfo(mockERC20Transfer)).toBe(true) + expect(isTransferTxInfo(mockSwapTransfer)).toBe(true) + }) + + it('should check an outGoing transfer', () => { + const incomingTx = mockTransferWithInfo({ + direction: TransferDirection.INCOMING, + }) + const outGoingTx = mockTransferWithInfo({ + direction: TransferDirection.OUTGOING, + }) + + expect(isOutgoingTransfer(outGoingTx)).toBe(true) + expect(isOutgoingTransfer(incomingTx)).toBe(false) + }) + + it('should check if a transaction is a custom transaction', () => { + const customTx = mockTransferWithInfo({ + type: TransactionInfoType.CUSTOM, + }) + const swapTx = mockTransferWithInfo({ + type: TransactionInfoType.SWAP_ORDER, + }) + + expect(isCustomTxInfo(customTx)).toBe(true) + expect(isCustomTxInfo(swapTx)).toBe(false) + }) + + it('should check if a transaction is a multi send transaction', () => { + const multiSend = mockTransferWithInfo({ + type: TransactionInfoType.CUSTOM, + methodName: 'multiSend', + actionCount: 2, + }) + const swapTx = mockTransferWithInfo({ + type: TransactionInfoType.SWAP_ORDER, + }) + + expect(isMultiSendTxInfo(multiSend)).toBe(true) + expect(isMultiSendTxInfo(swapTx)).toBe(false) + }) + + it('should check if it is possible to cancel a transaction', () => { + const multiSend = mockTransferWithInfo({ + type: TransactionInfoType.CUSTOM, + methodName: 'multiSend', + actionCount: 2, + isCancellation: true, + }) + const customTx = mockTransferWithInfo({ + type: TransactionInfoType.CUSTOM, + }) + + expect(isCancellationTxInfo(multiSend)).toBe(true) + expect(isCancellationTxInfo(customTx)).toBeFalsy() + }) + + it('should check if it is a transaction list item', () => { + expect(isTransactionListItem(mockListItemByType(TransactionListItemType.TRANSACTION))).toBe(true) + expect(isTransactionListItem(mockListItemByType(TransactionListItemType.DATE_LABEL))).toBe(false) + expect(isTransactionListItem(mockListItemByType(TransactionListItemType.LABEL))).toBe(false) + }) + + it('should check if it is a DateLabel transaction', () => { + expect(isDateLabel(mockListItemByType(TransactionListItemType.TRANSACTION))).toBe(false) + expect(isDateLabel(mockListItemByType(TransactionListItemType.DATE_LABEL))).toBe(true) + expect(isDateLabel(mockListItemByType(TransactionListItemType.LABEL))).toBe(false) + }) + + it('should check if it is a Label list item', () => { + expect(isLabelListItem(mockListItemByType(TransactionListItemType.TRANSACTION))).toBe(false) + expect(isLabelListItem(mockListItemByType(TransactionListItemType.DATE_LABEL))).toBe(false) + expect(isLabelListItem(mockListItemByType(TransactionListItemType.LABEL))).toBe(true) + }) + + it('should check if it is a creation transaction type', () => { + expect(isCreationTxInfo(mockTransferWithInfo({ type: TransactionInfoType.CREATION }))).toBe(true) + expect(isCreationTxInfo(mockTransferWithInfo({ type: TransactionInfoType.CUSTOM }))).toBe(false) + }) + + it('should check if it is a multisig execution', () => { + expect(isMultisigExecutionInfo(multisigTx)).toBe(true) + expect(isMultisigExecutionInfo(moduleTx)).toBe(false) + }) + + it('should check if it is a multisig execution', () => { + expect(isModuleExecutionInfo(moduleTx)).toBe(true) + expect(isModuleExecutionInfo(multisigTx)).toBe(false) + }) + + it('should check if it is a swap order', () => { + expect(isSwapOrderTxInfo(mockTransferWithInfo({ type: TransactionInfoType.SWAP_ORDER }))).toBe(true) + expect(isSwapOrderTxInfo(mockTransferWithInfo({ type: TransactionInfoType.CUSTOM }))).toBe(false) + }) +}) diff --git a/apps/mobile/src/utils/transaction-guards.ts b/apps/mobile/src/utils/transaction-guards.ts new file mode 100644 index 000000000..4733b7faa --- /dev/null +++ b/apps/mobile/src/utils/transaction-guards.ts @@ -0,0 +1,156 @@ +import uniq from 'lodash/uniq' +import { + type Cancellation, + type MultiSend, + ConflictType, + TransactionInfoType, + TransactionListItemType, + TransactionTokenType, + TransferDirection, +} from '@safe-global/store/gateway/types' +import type { + ModuleExecutionInfo, + TransactionDetails, + SwapTransferTransactionInfo, + TwapOrderTransactionInfo, + ConflictHeaderQueuedItem, + TransactionQueuedItem, + DateLabel, + TransferTransactionInfo, + SettingsChangeTransaction, + LabelQueuedItem, + MultisigExecutionInfo, + SwapOrderTransactionInfo, + Erc20Transfer, + Erc721Transfer, + NativeCoinTransfer, + Transaction, + CreationTransactionInfo, + CustomTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +import { HistoryTransactionItems, PendingTransactionItems } from '@safe-global/store/gateway/types' + +type TransactionInfo = Transaction['txInfo'] + +const TransactionStatus = { + AWAITING_CONFIRMATIONS: 'AWAITING_CONFIRMATIONS', + AWAITING_EXECUTION: 'AWAITING_EXECUTION', + CANCELLED: 'CANCELLED', + FAILED: 'FAILED', + SUCCESS: 'SUCCESS', +} + +export const isTxQueued = (value: Transaction['txStatus']): boolean => { + return [TransactionStatus.AWAITING_CONFIRMATIONS as string, TransactionStatus.AWAITING_EXECUTION as string].includes( + value, + ) +} + +export const getBulkGroupTxHash = (group: PendingTransactionItems[]) => { + const hashList = group.map((item) => { + if (isTransactionListItem(item)) { + return item.transaction.txHash + } + return null + }) + return uniq(hashList).length === 1 ? hashList[0] : undefined +} + +export const getTxHash = (item: TransactionQueuedItem): string => item.transaction.txHash as unknown as string + +export const isTransferTxInfo = (value: Transaction['txInfo']): value is TransferTransactionInfo => { + return value.type === TransactionInfoType.TRANSFER || isSwapTransferOrderTxInfo(value) +} + +export const isSettingsChangeTxInfo = (value: Transaction['txInfo']): value is SettingsChangeTransaction => { + return value.type === TransactionInfoType.SETTINGS_CHANGE +} +/** + * A fulfillment transaction for swap, limit or twap order is always a SwapOrder + * It cannot be a TWAP order + * + * @param value + */ +export const isSwapTransferOrderTxInfo = (value: Transaction['txInfo']): value is SwapTransferTransactionInfo => { + return value.type === TransactionInfoType.SWAP_TRANSFER +} + +export const isOutgoingTransfer = (txInfo: Transaction['txInfo']): boolean => { + return isTransferTxInfo(txInfo) && txInfo.direction.toUpperCase() === TransferDirection.OUTGOING +} + +export const isCustomTxInfo = (value: Transaction['txInfo']): value is CustomTransactionInfo => { + return value.type === TransactionInfoType.CUSTOM +} + +export const isMultiSendTxInfo = (value: Transaction['txInfo']): value is MultiSend => { + return ( + value.type === TransactionInfoType.CUSTOM && + value.methodName === 'multiSend' && + typeof value.actionCount === 'number' + ) +} + +export const isSwapOrderTxInfo = (value: TransactionInfo): value is SwapOrderTransactionInfo => { + return value.type === TransactionInfoType.SWAP_ORDER +} +export const isTwapOrderTxInfo = (value: Transaction['txInfo']): value is TwapOrderTransactionInfo => { + return value.type === TransactionInfoType.TWAP_ORDER +} + +export const isOrderTxInfo = (value: Transaction['txInfo']): value is SwapOrderTransactionInfo => { + return isSwapOrderTxInfo(value) || isTwapOrderTxInfo(value) +} + +export const isCancellationTxInfo = (value: Transaction['txInfo']): value is Cancellation => { + return isCustomTxInfo(value) && value.isCancellation +} + +export const isTransactionListItem = ( + value: HistoryTransactionItems | PendingTransactionItems, +): value is TransactionQueuedItem => { + return value.type === TransactionListItemType.TRANSACTION +} + +export const isConflictHeaderListItem = (value: PendingTransactionItems): value is ConflictHeaderQueuedItem => { + return value.type === TransactionListItemType.CONFLICT_HEADER +} + +export const isNoneConflictType = (transaction: TransactionQueuedItem) => { + return transaction.conflictType === ConflictType.NONE +} + +export const isDateLabel = (value: HistoryTransactionItems | PendingTransactionItems): value is DateLabel => { + return value.type === TransactionListItemType.DATE_LABEL +} + +export const isLabelListItem = (value: PendingTransactionItems | DateLabel): value is LabelQueuedItem => { + return value.type === TransactionListItemType.LABEL +} + +export const isCreationTxInfo = (value: TransactionInfo): value is CreationTransactionInfo => { + return value.type === TransactionInfoType.CREATION +} + +export const isMultisigExecutionInfo = ( + value?: Transaction['executionInfo'] | TransactionDetails['detailedExecutionInfo'], +): value is MultisigExecutionInfo => { + return value?.type === 'MULTISIG' +} + +export const isModuleExecutionInfo = ( + value?: Transaction['executionInfo'] | TransactionDetails['detailedExecutionInfo'], +): value is ModuleExecutionInfo => value?.type === 'MODULE' + +export const isNativeTokenTransfer = (value: TransferTransactionInfo['transferInfo']): value is NativeCoinTransfer => { + return value.type === TransactionTokenType.NATIVE_COIN +} + +export const isERC20Transfer = (value: TransferTransactionInfo['transferInfo']): value is Erc20Transfer => { + return value.type === TransactionTokenType.ERC20 +} + +export const isERC721Transfer = (value: TransferTransactionInfo['transferInfo']): value is Erc721Transfer => { + return value.type === TransactionTokenType.ERC721 +} diff --git a/apps/mobile/src/utils/transactions.tsx b/apps/mobile/src/utils/transactions.tsx new file mode 100644 index 000000000..87c4e487d --- /dev/null +++ b/apps/mobile/src/utils/transactions.tsx @@ -0,0 +1,30 @@ +import { GroupedTxs } from '@/src/features/TxHistory/utils' +import { Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +export const groupBulkTxs = ( + list: GroupedTxs, +): GroupedTxs => { + const grouped = list + .reduce>((resultItems, item) => { + if (Array.isArray(item) || item.type !== 'TRANSACTION') { + return resultItems.concat([item]) + } + const currentTxHash = item.transaction?.txHash + + const prevItem = resultItems[resultItems.length - 1] + if (!Array.isArray(prevItem)) { + return resultItems.concat([[item]]) + } + const prevTxHash = prevItem[0]?.transaction?.txHash + + if (currentTxHash && currentTxHash === prevTxHash) { + prevItem.push(item) + return resultItems + } + + return resultItems.concat([[item]]) + }, []) + .map((item) => (Array.isArray(item) && item.length === 1 ? item[0] : item)) + + return grouped +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 000000000..aac0107fa --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./*"], + "@safe-global/store/*": ["../../packages/store/src/*"], + "@safe-global/test/*": ["../../config/test/*"] + }, + "types": ["jest", "node"] + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], + "exclude": ["./__mocks__/**/*"] +} diff --git a/.dockerignore b/apps/web/.dockerignore similarity index 100% rename from .dockerignore rename to apps/web/.dockerignore diff --git a/.env.example b/apps/web/.env.example similarity index 78% rename from .env.example rename to apps/web/.env.example index ab53f2b04..69e9230b3 100644 --- a/.env.example +++ b/apps/web/.env.example @@ -2,10 +2,16 @@ 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= @@ -17,15 +23,11 @@ NEXT_PUBLIC_IS_PRODUCTION= # Latest supported safe version, used for upgrade prompts NEXT_PUBLIC_SAFE_VERSION= -# Access keys +# Sentry NEXT_PUBLIC_SENTRY_DSN= -NEXT_PUBLIC_BEAMER_ID= -# Wallet-specific variables -NEXT_PUBLIC_WC_PROJECT_ID= - -# E2E tests -NEXT_PUBLIC_CYPRESS_MNEMONIC= +# Beamer +NEXT_PUBLIC_BEAMER_ID= # Safe Gelato relay service NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_PRODUCTION= @@ -34,14 +36,11 @@ 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= -# Redefine -NEXT_PUBLIC_REDEFINE_API= - -# Social Login -NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_STAGING= -NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_PRODUCTION= +# Cypress wallet private keys +CYPRESS_WALLET_CREDENTIALS= +# [optional] Beamer keys for e2e tests +BEAMER_DATA_E2E= diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 000000000..93dcbb2da --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,6 @@ +# 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 new file mode 100644 index 000000000..2c3955378 --- /dev/null +++ b/apps/web/.storybook/main.ts @@ -0,0 +1,78 @@ +import type { StorybookConfig } from '@storybook/nextjs' + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-onboarding', + '@storybook/addon-links', + '@storybook/addon-essentials', + '@chromatic-com/storybook', + '@storybook/addon-interactions', + '@storybook/addon-themes', + '@storybook/addon-designs', + ], + framework: { + name: '@storybook/nextjs', + options: {}, + }, + webpackFinal: async (config) => { + config.module = config.module || {} + config.module.rules = config.module.rules || [] + + // This modifies the existing image rule to exclude .svg files + // since you want to handle those files with @svgr/webpack + const imageRule = config.module.rules.find((rule) => rule?.['test']?.test('.svg')) + if (imageRule) { + imageRule['exclude'] = /\.svg$/ + } + + config.module.rules.push({ + test: /\.svg$/i, + issuer: { and: [/\.(js|ts|md)x?$/] }, + use: [ + { + loader: '@svgr/webpack', + options: { + prettier: false, + svgo: false, + svgoConfig: { + plugins: [ + { + name: 'preset-default', + params: { + overrides: { removeViewBox: false }, + }, + }, + ], + }, + titleProp: true, + }, + }, + ], + }) + + return config + }, + docs: { + autodocs: 'tag', + }, + staticDirs: ['../public'], + + typescript: { + reactDocgen: 'react-docgen-typescript', + reactDocgenTypescriptOptions: { + // Speeds up Storybook build time + compilerOptions: { + allowSyntheticDefaultImports: false, + esModuleInterop: false, + }, + // Makes union prop types like variant and size appear as select controls + shouldExtractLiteralValuesFromEnum: true, + // Makes string and boolean types that can be undefined appear as inputs and switches + shouldRemoveUndefinedFromOptional: true, + // Filter out third-party props from node_modules except @mui packages + propFilter: (prop) => (prop.parent ? !/node_modules\/(?!@mui)/.test(prop.parent.fileName) : true), + }, + }, +} +export default config diff --git a/.storybook/preview.ts b/apps/web/.storybook/preview.ts similarity index 100% rename from .storybook/preview.ts rename to apps/web/.storybook/preview.ts diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 000000000..897e454b6 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,30 @@ +FROM node:18-alpine +RUN apk add --no-cache libc6-compat git python3 py3-pip make g++ libusb-dev eudev-dev linux-headers + +# Set working directory +WORKDIR /app + +# Copy root +COPY . . + +# Set working directory to the web app +WORKDIR apps/web + +# Enable corepack and configure yarn +RUN corepack enable +RUN yarn config set httpTimeout 300000 + +# Run any custom post-install scripts +RUN yarn install --immutable +RUN yarn after-install + +# Set environment variables +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 +ENV PORT 3000 + +# Expose the port +EXPOSE 3000 + +# Command to start the application +CMD ["yarn", "static-serve"] \ No newline at end of file diff --git a/apps/web/LICENSE b/apps/web/LICENSE new file mode 100644 index 000000000..e72bfddab --- /dev/null +++ b/apps/web/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 000000000..851ee7f9a --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,166 @@ +# Safe{Wallet} + +[![License](https://img.shields.io/github/license/safe-global/safe-wallet-web)](https://github.com/safe-global/safe-wallet-web/blob/main/LICENSE) +![Tests](https://img.shields.io/github/actions/workflow/status/safe-global/safe-wallet-web/test.yml?branch=main&label=tests) +![GitHub package.json version (branch)](https://img.shields.io/github/package-json/v/safe-global/safe-wallet-web) +[![GitPOAP Badge](https://public-api.gitpoap.io/v1/repo/safe-global/safe-wallet-web/badge)](https://www.gitpoap.io/gh/safe-global/safe-wallet-web) + +# Safe{Wallet} web app + +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/web`) is the frontend of the Safe{Wallet} web app. + +Safe{Wallet} is a smart contract wallet for Ethereum and other EVM chains. Based on Gnosis Safe multisig contracts. + +You can run commands for this workspace in two ways: + +1. **From the root of the monorepo using `yarn workspace` commands** +2. **From within the `apps/web` directory** + +## Prerequisites + +Except for the main monorepo prerequisites, no additional prerequisites are required for this workspace. + +## Setup the Project + +1. Install all dependencies from the **root of the monorepo**: + +```bash +yarn install +``` + +## Contributing + +Contributions, be it a bug report or a pull request, are very welcome. Please check +our [contribution guidelines](CONTRIBUTING.md) beforehand. + +## Getting started with local development + +### Environment variables + +Create a `.env` file with environment variables. You can use the `.env.example` file as a reference. + +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 | + +If you don't provide some of the variables, the corresponding features will be disabled in the UI. + +### Running the app locally + +From the root of the monorepo: + +```bash +yarn workspace @safe-global/web start +``` + +Or directly from the `apps/web` directory: + +```bash +yarn start +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the app. + +> [!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/web` directory you just need to omit the `workspace @safe-global/web`. + +## Lint + +ESLint: + +``` +yarn workspace @safe-global/web lint --fix +``` + +Prettier: + +``` +yarn workspace @safe-global/web prettier +``` + +## Tests + +Unit tests: + +``` +yarn workspace @safe-global/web test --watch +``` + +### Cypress tests + +Build a static site: + +``` +yarn workspace @safe-global/web build +``` + +Serve the static files: + +``` +yarn workspace @safe-global/web serve +``` + +Launch the Cypress UI: + +``` +yarn workspace @safe-global/web cypress:open +``` + +You can then choose which e2e tests to run. +Some tests will require signer private keys, please include them in your .env file + +## Component template + +To create a new component from a template: + +``` +yarn workspace @safe-global/web cmp MyNewComponent +``` + +## Pre-push hooks + +This repo has a pre-push hook that runs the linter (always) and the tests (if the `RUN_TESTS_ON_PUSH` env variable is +set to true) +before pushing. If you want to skip the hooks, you can use the `--no-verify` flag. + +## Frameworks + +This app is built using the following frameworks: + +- [Safe Core SDK](https://github.com/safe-global/safe-core-sdk) +- [Safe Gateway SDK](https://github.com/safe-global/safe-gateway-typescript-sdk) +- Next.js +- React +- Redux +- MUI +- ethers.js +- web3-onboard diff --git a/apps/web/cypress.config.js b/apps/web/cypress.config.js new file mode 100644 index 000000000..429e69f07 --- /dev/null +++ b/apps/web/cypress.config.js @@ -0,0 +1,74 @@ +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)) + +export default defineConfig({ + projectId: 'exhdra', + trashAssetsBeforeRuns: true, + reporter: 'junit', + reporterOptions: { + mochaFile: 'reports/junit-[hash].xml', + }, + retries: { + runMode: 3, + openMode: 0, + }, + 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) + } + + configureVisualRegression(on), + on('task', { + log(message) { + console.log(message) + return null + }, + }) + + on('after:spec', (spec, results) => { + if (results && results.video) { + const failures = results.tests.some((test) => test.attempts.some((attempt) => attempt.state === 'failed')) + if (!failures) { + fs.unlinkSync(results.video) + } + } + }) + + return config + }, + env: { + ...process.env, + visualRegressionType: 'regression', + visualRegressionBaseDirectory: 'cypress/snapshots/actual', + visualRegressionDiffDirectory: 'cypress/snapshots/diff', + visualRegressionGenerateDiff: 'fail', + }, + baseUrl: 'http://localhost:3000', + testIsolation: false, + hideXHR: true, + defaultCommandTimeout: 10000, + pageLoadTimeout: 60000, + numTestsKeptInMemory: 20, + }, + + chromeWebSecurity: false, +}) diff --git a/cypress/ci.json b/apps/web/cypress/ci.json similarity index 100% rename from cypress/ci.json rename to apps/web/cypress/ci.json diff --git a/cypress/e2e/happypath/recovery_hp_1.cy.js b/apps/web/cypress/e2e/happypath/recovery_hp_1.cy.js similarity index 91% rename from cypress/e2e/happypath/recovery_hp_1.cy.js rename to apps/web/cypress/e2e/happypath/recovery_hp_1.cy.js index bb036e11f..e542eead3 100644 --- a/cypress/e2e/happypath/recovery_hp_1.cy.js +++ b/apps/web/cypress/e2e/happypath/recovery_hp_1.cy.js @@ -34,13 +34,7 @@ describe('Recovery happy path tests 1', () => { recovery.clickOnNextBtn() tx.executeFlow_1() recovery.verifyRecovererAdded([constants.SEPOLIA_OWNER_2_SHORT]) - recovery.clearRecoverers() - - // recovery.removeRecoverer(0, constants.SEPOLIA_OWNER_2) - // recovery.clickOnNextBtn() - // tx.executeFlow_1() - recovery.getSetupRecoveryBtn() }) }) diff --git a/cypress/e2e/happypath/recovery_hp_2.cy.js b/apps/web/cypress/e2e/happypath/recovery_hp_2.cy.js similarity index 100% rename from cypress/e2e/happypath/recovery_hp_2.cy.js rename to apps/web/cypress/e2e/happypath/recovery_hp_2.cy.js diff --git a/cypress/e2e/happypath/recovery_hp_3.cy.js b/apps/web/cypress/e2e/happypath/recovery_hp_3.cy.js similarity index 100% rename from cypress/e2e/happypath/recovery_hp_3.cy.js rename to apps/web/cypress/e2e/happypath/recovery_hp_3.cy.js diff --git a/apps/web/cypress/e2e/happypath/recovery_hp_4.cy.js b/apps/web/cypress/e2e/happypath/recovery_hp_4.cy.js new file mode 100644 index 000000000..74961e82f --- /dev/null +++ b/apps/web/cypress/e2e/happypath/recovery_hp_4.cy.js @@ -0,0 +1,42 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as owner from '../pages/owners.pages.js' +import * as recovery from '../pages/recovery.pages.js' +import * as tx from '../pages/transactions.page.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +let recoverySafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Recovery happy path tests 4', () => { + before(async () => { + recoverySafes = await getSafes(CATEGORIES.recovery) + }) + + beforeEach(() => { + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_5) + cy.clearLocalStorage() + main.acceptCookies() + }) + + // Check that recovery can be setup and removed from modules + it('Recovery setup happy path 4', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + recovery.clearRecoverers() + recovery.clickOnSetupRecoveryBtn() + recovery.clickOnSetupRecoveryModalBtn() + recovery.clickOnNextBtn() + recovery.enterRecovererAddress(constants.SEPOLIA_OWNER_2) + recovery.agreeToTerms() + recovery.clickOnNextBtn() + tx.executeFlow_1() + recovery.verifyRecovererAdded([constants.SEPOLIA_OWNER_2_SHORT]) + cy.visit(constants.modulesUrl + recoverySafes.SEP_RECOVERY_SAFE_5) + recovery.deleteRecoveryModule() + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_5) + recovery.getSetupRecoveryBtn() + }) +}) diff --git a/cypress/e2e/happypath/sendfunds_connected_wallet.cy.js b/apps/web/cypress/e2e/happypath/sendfunds_connected_wallet.cy.js similarity index 88% rename from cypress/e2e/happypath/sendfunds_connected_wallet.cy.js rename to apps/web/cypress/e2e/happypath/sendfunds_connected_wallet.cy.js index 532b72cce..402ad6de5 100644 --- a/cypress/e2e/happypath/sendfunds_connected_wallet.cy.js +++ b/apps/web/cypress/e2e/happypath/sendfunds_connected_wallet.cy.js @@ -8,11 +8,11 @@ import * as nfts from '../pages/nfts.pages' import * as ls from '../../support/localstorage_data.js' import { ethers } from 'ethers' import SafeApiKit from '@safe-global/api-kit' -import { createEthersAdapter, createSigners } from '../../support/api/utils_ether' +import { createSigners } from '../../support/api/utils_ether' import { createSafes } from '../../support/api/utils_protocolkit' import { contracts, abi_qtrust, abi_nft_pc2 } from '../../support/api/contracts' -import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' +import * as fundSafes from '../../fixtures/safes/funds.json' const transferAmount = '1' @@ -40,10 +40,6 @@ const tokenContract = new ethers.Contract(contractAddress, abi_qtrust, provider) const nftContract = new ethers.Contract(nftContractAddress, abi_nft_pc2, provider) const owner1Signer = signers[0] -const owner2Signer = signers[1] - -const ethAdapterOwner1 = createEthersAdapter(owner1Signer) -const ethAdapterOwner2 = createEthersAdapter(owner2Signer) function visit(url) { cy.visit(url) @@ -51,12 +47,14 @@ function visit(url) { describe('Send funds with connected signer happy path tests', { defaultCommandTimeout: 60000 }, () => { before(async () => { - safesData = await getSafes(CATEGORIES.funds) - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__cookies, ls.cookies.acceptedCookies) - main.addToLocalStorage( - constants.localStorageKeys.SAFE_v2__tokenlist_onboarding, - ls.cookies.acceptedTokenListOnboarding, - ) + cy.clearLocalStorage().then(() => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2_cookies, ls.cookies.acceptedCookies) + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__tokenlist_onboarding, + ls.cookies.acceptedTokenListOnboarding, + ) + }) + safesData = fundSafes apiKit = new SafeApiKit({ chainId: BigInt(1), txServiceUrl: constants.stagingTxServiceUrl, @@ -65,8 +63,8 @@ describe('Send funds with connected signer happy path tests', { defaultCommandTi outgoingSafeAddress = safesData.SEP_FUNDS_SAFE_6.substring(4) const safeConfigurations = [ - { ethAdapter: ethAdapterOwner1, safeAddress: outgoingSafeAddress }, - { ethAdapter: ethAdapterOwner2, safeAddress: outgoingSafeAddress }, + { signer: walletCredentials.OWNER_1_PRIVATE_KEY, safeAddress: outgoingSafeAddress, provider }, + { signer: walletCredentials.OWNER_2_PRIVATE_KEY, safeAddress: outgoingSafeAddress, provider }, ] safes = await createSafes(safeConfigurations) @@ -104,6 +102,8 @@ describe('Send funds with connected signer happy path tests', { defaultCommandTi }) await tx.wait() main.verifyNonceChange(network_pref + originatingSafe, currentNonce + 1) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() }) }) }) @@ -157,6 +157,8 @@ describe('Send funds with connected signer happy path tests', { defaultCommandTi const safeTx = await apiKit.getTransaction(safeTxHashofExistingTx) await protocolKitOwner2_S3.executeTransaction(safeTx) main.verifyNonceChange(network_pref + targetSafe, currentNonce + 1) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() }) }) @@ -190,6 +192,8 @@ describe('Send funds with connected signer happy path tests', { defaultCommandTi await tx.wait() main.verifyNonceChange(network_pref + originatingSafe, currentNonce + 1) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() }) }) }) diff --git a/apps/web/cypress/e2e/happypath/sendfunds_queue_1.cy.js b/apps/web/cypress/e2e/happypath/sendfunds_queue_1.cy.js new file mode 100644 index 000000000..315af1406 --- /dev/null +++ b/apps/web/cypress/e2e/happypath/sendfunds_queue_1.cy.js @@ -0,0 +1,209 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as assets from '../pages/assets.pages' +import * as tx from '../pages/transactions.page' +import { ethers } from 'ethers' +import SafeApiKit from '@safe-global/api-kit' +import { createSigners } from '../../support/api/utils_ether' +import { createSafes } from '../../support/api/utils_protocolkit' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as ls from '../../support/localstorage_data.js' +import * as navigation from '../pages/navigation.page.js' +import * as fundSafes from '../../fixtures/safes/funds.json' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const receiver = walletCredentials.OWNER_2_WALLET_ADDRESS +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +const tokenAmount = '0.0001' +const netwrok = 'sepolia' +const network_pref = 'sep:' +const unit_eth = 'ether' +let apiKit, + protocolKitOwnerS1, + protocolKitOwnerS2, + protocolKitOwner1_S3, + protocolKitOwner2_S3, + existingSafeAddress1, + existingSafeAddress2, + existingSafeAddress3 + +let safes = [] +let safesData = [] + +const provider = new ethers.InfuraProvider(netwrok, Cypress.env('INFURA_API_KEY')) +const privateKeys = [walletCredentials.OWNER_1_PRIVATE_KEY, walletCredentials.OWNER_2_PRIVATE_KEY] + +const signers = createSigners(privateKeys, provider) + +const owner1Signer = signers[0] +const owner2Signer = signers[1] + +function visit(url) { + cy.visit(url) +} + +function executeTransactionFlow(fromSafe) { + visit(constants.transactionQueueUrl + fromSafe) + wallet.connectSigner(signer) + assets.clickOnConfirmBtn(0) + tx.executeFlow_1() + cy.wait(5000) +} + +describe('Send funds from queue happy path tests 1', () => { + before(async () => { + cy.clearLocalStorage().then(() => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2_cookies, ls.cookies.acceptedCookies) + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__tokenlist_onboarding, + ls.cookies.acceptedTokenListOnboarding, + ) + }) + + safesData = fundSafes + apiKit = new SafeApiKit({ + chainId: BigInt(1), + txServiceUrl: constants.stagingTxServiceUrl, + }) + + existingSafeAddress1 = safesData.SEP_FUNDS_SAFE_3.substring(4) + existingSafeAddress2 = safesData.SEP_FUNDS_SAFE_4.substring(4) + existingSafeAddress3 = safesData.SEP_FUNDS_SAFE_5.substring(4) + + const safeConfigurations = [ + { signer: privateKeys[0], safeAddress: existingSafeAddress1, provider }, + { signer: privateKeys[0], safeAddress: existingSafeAddress2, provider }, + { signer: privateKeys[0], safeAddress: existingSafeAddress3, provider }, + { signer: privateKeys[1], safeAddress: existingSafeAddress3, provider }, + ] + + safes = await createSafes(safeConfigurations) + + protocolKitOwnerS1 = safes[0] + protocolKitOwnerS2 = safes[1] + protocolKitOwner1_S3 = safes[2] + protocolKitOwner2_S3 = safes[3] + }) + + it('Verify confirmation and execution of native token queued tx by second signer with connected wallet', () => { + cy.wrap(null) + .then(() => { + return main.fetchCurrentNonce(network_pref + existingSafeAddress1) + }) + .then(async (currentNonce) => { + const amount = ethers.parseUnits(tokenAmount, unit_eth).toString() + const safeTransactionData = { + to: receiver, + data: '0x', + value: amount.toString(), + } + + const safeTransaction = await protocolKitOwnerS1.createTransaction({ transactions: [safeTransactionData] }) + const safeTxHash = await protocolKitOwnerS1.getTransactionHash(safeTransaction) + const senderSignature = await protocolKitOwnerS1.signHash(safeTxHash) + const safeAddress = existingSafeAddress1 + + await apiKit.proposeTransaction({ + safeAddress, + safeTransactionData: safeTransaction.data, + safeTxHash, + senderAddress: await owner1Signer.getAddress(), + senderSignature: senderSignature.data, + }) + + executeTransactionFlow(safeAddress) + cy.wait(5000) + main.verifyNonceChange(network_pref + safeAddress, currentNonce + 1) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + }) + + it.skip('Verify confirmation and execution of native token queued tx by second signer with relayer', () => { + function executeTransactionFlow(fromSafe) { + visit(constants.transactionQueueUrl + fromSafe) + wallet.connectSigner(signer) + assets.clickOnConfirmBtn(0) + tx.executeFlow_2() + cy.wait(5000) + } + cy.wrap(null) + .then(() => { + return main.fetchCurrentNonce(network_pref + existingSafeAddress2) + }) + .then(async (currentNonce) => { + const amount = ethers.parseUnits(tokenAmount, unit_eth).toString() + const safeTransactionData = { + to: receiver, + data: '0x', + value: amount.toString(), + } + + const safeTransaction = await protocolKitOwnerS2.createTransaction({ transactions: [safeTransactionData] }) + const safeTxHash = await protocolKitOwnerS2.getTransactionHash(safeTransaction) + const senderSignature = await protocolKitOwnerS2.signHash(safeTxHash) + const safeAddress = existingSafeAddress2 + + await apiKit.proposeTransaction({ + safeAddress, + safeTransactionData: safeTransaction.data, + safeTxHash, + senderAddress: await owner1Signer.getAddress(), + senderSignature: senderSignature.data, + }) + + executeTransactionFlow(safeAddress) + cy.wait(5000) + main.verifyNonceChange(network_pref + safeAddress, currentNonce + 1) + }) + }) + + it('Verify 1 signer can execute a tx confirmed by 2 signers', { defaultCommandTimeout: 300000 }, () => { + function executeTransaction(fromSafe) { + visit(constants.transactionQueueUrl + fromSafe) + wallet.connectSigner(signer) + assets.clickOnExecuteBtn(0) + tx.executeFlow_3() + cy.wait(5000) + } + cy.wrap(null) + .then(() => { + return main.fetchCurrentNonce(network_pref + existingSafeAddress3) + }) + .then(async (currentNonce) => { + const amount = ethers.parseUnits(tokenAmount, unit_eth).toString() + const safeTransactionData = { + to: receiver, + data: '0x', + value: amount.toString(), + } + + const safeTransaction = await protocolKitOwner1_S3.createTransaction({ transactions: [safeTransactionData] }) + const safeTxHash = await protocolKitOwner1_S3.getTransactionHash(safeTransaction) + const senderSignature = await protocolKitOwner1_S3.signHash(safeTxHash) + const safeAddress = existingSafeAddress3 + + await apiKit.proposeTransaction({ + safeAddress, + safeTransactionData: safeTransaction.data, + safeTxHash, + senderAddress: await owner1Signer.getAddress(), + senderSignature: senderSignature.data, + }) + + const pendingTransactions = await apiKit.getPendingTransactions(safeAddress) + const safeTxHashofExistingTx = pendingTransactions.results[0].safeTxHash + + const signature = await protocolKitOwner2_S3.signHash(safeTxHashofExistingTx) + await apiKit.confirmTransaction(safeTxHashofExistingTx, signature.data) + + executeTransaction(safeAddress) + cy.wait(5000) + main.verifyNonceChange(network_pref + safeAddress, currentNonce + 1) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + }) +}) diff --git a/cypress/e2e/happypath/sendfunds_relay.cy.js b/apps/web/cypress/e2e/happypath/sendfunds_relay.cy.js similarity index 88% rename from cypress/e2e/happypath/sendfunds_relay.cy.js rename to apps/web/cypress/e2e/happypath/sendfunds_relay.cy.js index 1f4a65bb5..8e6f96800 100644 --- a/cypress/e2e/happypath/sendfunds_relay.cy.js +++ b/apps/web/cypress/e2e/happypath/sendfunds_relay.cy.js @@ -8,11 +8,11 @@ import * as nfts from '../pages/nfts.pages' import * as ls from '../../support/localstorage_data.js' import { ethers } from 'ethers' import SafeApiKit from '@safe-global/api-kit' -import { createEthersAdapter, createSigners } from '../../support/api/utils_ether' +import { createSigners } from '../../support/api/utils_ether' import { createSafes } from '../../support/api/utils_protocolkit' import { contracts, abi_qtrust, abi_nft_pc2 } from '../../support/api/contracts' -import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' +import * as fundSafes from '../../fixtures/safes/funds.json' const transferAmount = '1' @@ -41,22 +41,21 @@ const nftContract = new ethers.Contract(nftContractAddress, abi_nft_pc2, provide const owner1Signer = signers[0] const owner2Signer = signers[1] -const ethAdapterOwner1 = createEthersAdapter(owner1Signer) -const ethAdapterOwner2 = createEthersAdapter(owner2Signer) - function visit(url) { cy.visit(url) } -// TODO: Relay only allows 5 txs per hour. +// TODO: Relay only allows 5 txs per day. describe('Send funds with relay happy path tests', { defaultCommandTimeout: 300000 }, () => { before(async () => { - safesData = await getSafes(CATEGORIES.funds) - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__cookies, ls.cookies.acceptedCookies) - main.addToLocalStorage( - constants.localStorageKeys.SAFE_v2__tokenlist_onboarding, - ls.cookies.acceptedTokenListOnboarding, - ) + cy.clearLocalStorage().then(() => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2_cookies, ls.cookies.acceptedCookies) + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__tokenlist_onboarding, + ls.cookies.acceptedTokenListOnboarding, + ) + }) + safesData = fundSafes apiKit = new SafeApiKit({ chainId: BigInt(1), txServiceUrl: constants.stagingTxServiceUrl, @@ -65,8 +64,8 @@ describe('Send funds with relay happy path tests', { defaultCommandTimeout: 3000 outgoingSafeAddress = safesData.SEP_FUNDS_SAFE_8.substring(4) const safeConfigurations = [ - { ethAdapter: ethAdapterOwner1, safeAddress: outgoingSafeAddress }, - { ethAdapter: ethAdapterOwner2, safeAddress: outgoingSafeAddress }, + { signer: privateKeys[0], safeAddress: outgoingSafeAddress, provider }, + { signer: privateKeys[1], safeAddress: outgoingSafeAddress, provider }, ] safes = await createSafes(safeConfigurations) @@ -100,13 +99,14 @@ describe('Send funds with relay happy path tests', { defaultCommandTimeout: 3000 throw new Error(main.noRelayAttemptsError) } executeTransactionFlow(originatingSafe, walletAddress.toString(), transferAmount).then(async () => { - main.checkTokenBalanceIsNull(network_pref + originatingSafe, constants.tokenAbbreviation.tpcc) const contractWithWallet = nftContract.connect(owner1Signer) const tx = await contractWithWallet.safeTransferFrom(walletAddress.toString(), originatingSafe, 2, { gasLimit: 200000, }) await tx.wait() main.verifyNonceChange(network_pref + originatingSafe, currentNonce + 1) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() }) }) }) @@ -165,6 +165,8 @@ describe('Send funds with relay happy path tests', { defaultCommandTimeout: 3000 const safeTx = await apiKit.getTransaction(safeTxHashofExistingTx) await protocolKitOwner2_S3.executeTransaction(safeTx) main.verifyNonceChange(network_pref + targetSafe, currentNonce + 1) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() }) }) }) @@ -205,6 +207,8 @@ describe('Send funds with relay happy path tests', { defaultCommandTimeout: 3000 await tx.wait() main.verifyNonceChange(network_pref + originatingSafe, currentNonce + 1) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() }) }) }) diff --git a/cypress/e2e/happypath/tx_history_filter_hp_1.cy.js b/apps/web/cypress/e2e/happypath/tx_history_filter_hp_1.cy.js similarity index 100% rename from cypress/e2e/happypath/tx_history_filter_hp_1.cy.js rename to apps/web/cypress/e2e/happypath/tx_history_filter_hp_1.cy.js diff --git a/apps/web/cypress/e2e/happypath/tx_history_filter_hp_2.cy.js b/apps/web/cypress/e2e/happypath/tx_history_filter_hp_2.cy.js new file mode 100644 index 000000000..39c322cce --- /dev/null +++ b/apps/web/cypress/e2e/happypath/tx_history_filter_hp_2.cy.js @@ -0,0 +1,36 @@ +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 ls from '../../support/localstorage_data.js' + +let staticSafes = [] + +describe('Tx history happy path tests 2', () => { + before(async () => { + cy.clearLocalStorage().then(() => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2_cookies, ls.cookies.acceptedCookies) + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__tokenlist_onboarding, + ls.cookies.acceptedTokenListOnboarding, + ) + }) + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_8) + }) + + it('Verify a user can filter outgoing transactions by module', () => { + const moduleAddress = 'sep:0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134' + const uiDate = 'Jan 30, 2024 - 10:53:48 AM' + + createTx.clickOnFilterBtn() + createTx.setTxType(createTx.filterTypes.module) + createTx.fillFilterForm({ address: moduleAddress }) + createTx.clickOnApplyBtn() + createTx.verifyNumberOfTransactions(1) + createTx.checkTxItemDate(1, uiDate) + }) +}) diff --git a/apps/web/cypress/e2e/happypath_2/add_owner.cy.js b/apps/web/cypress/e2e/happypath_2/add_owner.cy.js new file mode 100644 index 000000000..2cb31793f --- /dev/null +++ b/apps/web/cypress/e2e/happypath_2/add_owner.cy.js @@ -0,0 +1,100 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' +import * as addressBook from '../pages/address_book.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' +import * as navigation from '../pages/navigation.page' +import { getEvents, events, checkDataLayerEvents } from '../../support/utils/gtag.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY + +describe('Happy path Add Owners tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + }) + + it( + 'Verify creation, confirmation and deletion of Add owner tx. GA tx_confirm', + { defaultCommandTimeout: 30000 }, + () => { + const tx_confirmed = [ + { + eventLabel: events.txConfirmedAddOwner.eventLabel, + eventCategory: events.txConfirmedAddOwner.category, + eventType: events.txConfirmedAddOwner.eventType, + safeAddress: staticSafes.SEP_STATIC_SAFE_24.slice(6), + }, + ] + function step1() { + // Clean txs in the queue + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_24) + wallet.connectSigner(signer2) + cy.wait(5000) + createTx.deleteAllTx() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_24) + wallet.connectSigner(signer2) + owner.waitForConnectionStatus() + owner.openAddOwnerWindow() + owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) + createTx.changeNonce(1) + owner.clickOnNextBtn() + createTx.clickOnSignTransactionBtn() + createTx.clickViewTransaction() + + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer) + } + + function step2() { + createTx.clickOnConfirmTransactionBtn() + createTx.clickOnNoLaterOption() + createTx.clickOnSignTransactionBtn() + + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + getEvents() + checkDataLayerEvents(tx_confirmed) + wallet.connectSigner(signer2) + createTx.deleteTx() + } + + step1() + cy.get('body').then(($body) => { + if ($body.find(`button:contains("${createTx.executeStr}")`).length > 0) { + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer2) + createTx.deleteTx() + cy.wait(5000) + step1() + step2() + } else { + createTx.clickOnConfirmTransactionBtn() + createTx.clickOnNoLaterOption() + createTx.clickOnSignTransactionBtn() + + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + getEvents() + checkDataLayerEvents(tx_confirmed) + wallet.connectSigner(signer2) + createTx.deleteTx() + } + }) + }, + ) +}) 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 new file mode 100644 index 000000000..e6f091567 --- /dev/null +++ b/apps/web/cypress/e2e/happypath_2/create_safe_cf.cy.js @@ -0,0 +1,47 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as createwallet from '../pages/create_wallet.pages' +import * as owner from '../pages/owners.pages' +import * as wallet from '../../support/utils/wallet.js' +import { getEvents, events, checkDataLayerEvents } from '../../support/utils/gtag.js' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +// DO NOT use OWNER_2_PRIVATE_KEY for safe creation. Used for CF safes. +const signer = walletCredentials.OWNER_2_PRIVATE_KEY + +describe('CF Safe creation happy path tests', () => { + beforeEach(() => { + cy.visit(constants.welcomeUrl + '?chain=sep') + // Required for data layer + cy.clearLocalStorage() + main.acceptCookies() + getEvents() + }) + + it('CF creation happy path. GA safe_created', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clickOnNextBtn() + createwallet.clickOnNextBtn() + createwallet.selectPayLaterOption() + createwallet.clickOnReviewStepNextBtn() + cy.wait(1000) + main.getAddedSafeAddressFromLocalStorage(constants.networkKeys.sepolia, 0).then((address) => { + const safe_created = [ + { + eventLabel: events.safeCreatedCF.eventLabel, + eventCategory: events.safeCreatedCF.category, + eventAction: events.safeCreatedCF.action, + eventType: events.safeCreatedCF.eventType, + event: events.safeCreatedCF.eventName, + safeAddress: address.slice(2), + }, + ] + checkDataLayerEvents(safe_created) + createwallet.clickOnLetsGoBtn() + createwallet.verifyCFSafeCreated() + }) + }) +}) 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 new file mode 100644 index 000000000..3ed554398 --- /dev/null +++ b/apps/web/cypress/e2e/happypath_2/multichain_create_safe.cy.js @@ -0,0 +1,50 @@ +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 createwallet from '../pages/create_wallet.pages' +import * as createtx from '../pages/create_tx.pages.js' +import * as tx from '../pages/transactions.page.js' +import * as owner from '../pages/owners.pages' +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 + +describe('Happy path Multichain safe creation tests', { defaultCommandTimeout: 60000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.welcomeUrl + '?chain=sep') + cy.wait(2000) + wallet.connectSigner(signer) + }) + + it('Verify that L2 safe created during multichain safe creation has 1.4.1 L2 contract after deployment', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clickOnNetwrokRemoveIcon() + createwallet.selectMultiNetwork(1, constants.networks.ethereum.toLowerCase()) + createwallet.selectMultiNetwork(1, constants.networks.sepolia.toLowerCase()) + createwallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) + createwallet.clickOnNextBtn() + createwallet.clickOnNextBtn() + createwallet.clickOnReviewStepNextBtn() + createwallet.clickOnLetsGoBtn() + + cy.url().then((currentUrl) => { + const safe = `sep:${main.getSafeAddressFromUrl(currentUrl)}` + cy.visit(constants.homeUrl + safe) + createwallet.clickOnActivateAccountBtn(0) + createwallet.selectRelayOption() + createwallet.clickOnFinalActivateAccountBtn() + createwallet.clickOnLetsGoBtn() + cy.visit(constants.setupUrl + safe) + main.verifyValuesExist(navigation.setupSection, [constants.safeContractVersions.v1_4_1_L2]) + }) + }) +}) diff --git a/apps/web/cypress/e2e/happypath_2/proposers.cy.js b/apps/web/cypress/e2e/happypath_2/proposers.cy.js new file mode 100644 index 000000000..411548b5e --- /dev/null +++ b/apps/web/cypress/e2e/happypath_2/proposers.cy.js @@ -0,0 +1,51 @@ +import * as constants from '../../support/constants.js' +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' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const signer3 = walletCredentials.OWNER_3_PRIVATE_KEY +const addedProposer = walletCredentials.OWNER_3_WALLET_ADDRESS +const proposerAddress = 'sep:0xC16D...6fED' +const proposerAddress2 = '0x8eeC...2a3b' +const proposerName2 = 'Proposer 2' +const proposerName = 'Proposer 1' +const changedProposerName = 'Changed proposer name' + +describe('Happy path Proposers tests', () => { + 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', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_31) + wallet.connectSigner(signer3) + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + proposer.verifyEditProposerBtnDisabled(proposerAddress) + + proposer.clickOnEditProposerBtn(proposerAddress2) + proposer.enterProposerName(changedProposerName) + proposer.clickOnSubmitProposerBtn() + proposer.checkProposerData([changedProposerName]) + + proposer.clickOnEditProposerBtn(proposerAddress2) + proposer.enterProposerName(proposerName2) + proposer.clickOnSubmitProposerBtn() + proposer.checkProposerData([proposerName2]) + }) + + it('Verify a proposer can be added', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_32) + wallet.connectSigner(signer) + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + proposer.deleteAllProposers() + proposer.clickOnAddProposerBtn() + proposer.enterProposerData(addedProposer, proposerName) + proposer.clickOnSubmitProposerBtn() + proposer.verifyProposerSuccessMsgDisplayed() + }) +}) diff --git a/apps/web/cypress/e2e/happypath_2/swaps.cy.js b/apps/web/cypress/e2e/happypath_2/swaps.cy.js new file mode 100644 index 000000000..a14575d7f --- /dev/null +++ b/apps/web/cypress/e2e/happypath_2/swaps.cy.js @@ -0,0 +1,99 @@ +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 { 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) + }) + + beforeEach(() => { + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_1) + main.waitForHistoryCallToComplete() + wallet.connectSigner(signer) + iframeSelector = `iframe[src*="${constants.swapWidget}"]` + }) + + it( + 'Verify an order can be created, signed by second signer and deleted. GA tx_confirm, tx_created', + { defaultCommandTimeout: 30000 }, + () => { + const tx_created = [ + { + eventLabel: events.txCreatedSwap.eventLabel, + eventCategory: events.txCreatedSwap.category, + eventType: events.txCreatedSwap.eventType, + safeAddress: staticSafes.SEP_STATIC_SAFE_30.slice(6), + }, + ] + const tx_confirmed = [ + { + eventLabel: events.txConfirmedSwap.eventLabel, + eventCategory: events.txConfirmedSwap.category, + eventType: events.txConfirmedSwap.eventType, + safeAddress: staticSafes.SEP_STATIC_SAFE_30.slice(6), + }, + ] + // Clean txs in the queue + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_30) + cy.wait(5000) + create_tx.deleteAllTx() + + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_30) + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.clickOnSettingsBtn() + swaps.setSlippage('0.30') + swaps.setExpiry('2') + swaps.clickOnSettingsBtn() + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(200) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.clickOnExceeFeeChkbox() + swaps.clickOnSwapBtn() + swaps.clickOnSwapBtn() + }) + create_tx.changeNonce(0) + create_tx.clickOnSignTransactionBtn() + create_tx.clickViewTransaction() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer3) + + cy.wait(5000) + create_tx.verifyConfirmTransactionBtnIsVisible() + create_tx.clickOnConfirmTransactionBtn() + create_tx.clickOnNoLaterOption() + + create_tx.clickOnSignTransactionBtn() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer) + create_tx.deleteTx() + + getEvents() + checkDataLayerEvents(tx_created) + checkDataLayerEvents(tx_confirmed) + }, + ) +}) diff --git a/cypress/e2e/pages/address_book.page.js b/apps/web/cypress/e2e/pages/address_book.page.js similarity index 99% rename from cypress/e2e/pages/address_book.page.js rename to apps/web/cypress/e2e/pages/address_book.page.js index 8f998797e..4efcd976f 100644 --- a/cypress/e2e/pages/address_book.page.js +++ b/apps/web/cypress/e2e/pages/address_book.page.js @@ -23,6 +23,7 @@ const exportSummary = '[data-testid="export-summary"]' const sendBtn = '[data-testid="send-btn"]' const nextPageBtn = 'button[aria-label="Go to next page"]' const previousPageBtn = 'button[aria-label="Go to previous page"]' +export const entryDialog = '[data-testid="entry-dialog"]' //TODO Move to specific component const moreActionIcon = '[data-testid="MoreHorizIcon"]' diff --git a/cypress/e2e/pages/assets.pages.js b/apps/web/cypress/e2e/pages/assets.pages.js similarity index 99% rename from cypress/e2e/pages/assets.pages.js rename to apps/web/cypress/e2e/pages/assets.pages.js index b92af57eb..a19f57b9b 100644 --- a/cypress/e2e/pages/assets.pages.js +++ b/apps/web/cypress/e2e/pages/assets.pages.js @@ -241,6 +241,8 @@ export function checkHiddenTokenBtnCounter(value) { } export function verifyEachRowHasCheckbox(state) { + const tokens = [currencyTestTokenB, currencyTestTokenA] + main.verifyTextVisibility(tokens) cy.get(tokenListTable).within(() => { cy.get('tbody').within(() => { cy.get('tr').each(($row) => { diff --git a/cypress/e2e/pages/batches.pages.js b/apps/web/cypress/e2e/pages/batches.pages.js similarity index 83% rename from cypress/e2e/pages/batches.pages.js rename to apps/web/cypress/e2e/pages/batches.pages.js index 85e98e635..c8fe065e4 100644 --- a/cypress/e2e/pages/batches.pages.js +++ b/apps/web/cypress/e2e/pages/batches.pages.js @@ -3,12 +3,13 @@ import * as constants from '../../support/constants' const tokenSelectorText = 'G(ö|oe)rli Ether' const noLaterString = 'No, later' const yesExecuteString = 'Yes, execute' -const newTransactionTitle = 'New transaction' +export const newTransactionBtnStr = 'New transaction' const sendTokensButn = 'Send tokens' const nextBtn = 'Next' const executeBtn = 'Execute' -const addToBatchBtn = 'Add to batch' +export const addToBatchBtn = 'Add to batch' const confirmBatchBtn = 'Confirm batch' +export const batchedTxs = 'Batched transactions' export const closeModalBtnBtn = '[data-testid="CloseIcon"]' export const deleteTransactionbtn = '[title="Delete transaction"]' @@ -26,6 +27,8 @@ const listBox = 'ul[role="listbox"]' const amountInput = '[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) { fillTransactionData(EOA, amount) @@ -35,6 +38,7 @@ export function addToBatch(EOA, currentNonce, amount, verify = false) { executeTransaction() } addToBatchButton() + cy.contains(transactionAddedToBatchStr).click().should('not.be.visible') } function fillTransactionData(EOA, amount) { @@ -61,7 +65,7 @@ function executeTransaction() { } function addToBatchButton() { - cy.contains(addToBatchBtn).should('be.visible').and('not.be.disabled').click() + cy.get('button').contains(addToBatchBtn).click() } export function openBatchtransactionsModal() { @@ -106,3 +110,16 @@ export function verifyTransactionAdded() { export function verifyBatchIconCount(count) { cy.get(batchTxCounter).contains(count) } + +export function verifyNewTxButtonStatus(param) { + cy.get('button').contains(newTransactionBtnStr).should(param) +} + +export function isTxExpanded(index, option) { + let item = option ? expandedItem : collapsedItem + cy.contains(batchedTxs) + .parent() + .within(() => { + cy.get('li').eq(index).find(item) + }) +} diff --git a/apps/web/cypress/e2e/pages/create_tx.pages.js b/apps/web/cypress/e2e/pages/create_tx.pages.js new file mode 100644 index 000000000..43d335800 --- /dev/null +++ b/apps/web/cypress/e2e/pages/create_tx.pages.js @@ -0,0 +1,668 @@ +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' + +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 nonceInput = 'input[name="nonce"]' +const gasLimitInput = '[name="gasLimit"]' +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 payNowExecMethod = '[data-testid="pay-now-execution-method"]' +const addToBatchBtn = '[data-track="batching: Add to batch"]' +const accordionDetails = '[data-testid="accordion-details"]' +const copyIcon = '[data-testid="copy-btn-icon"]' +const transactionSideList = '[data-testid="transaction-actions-list"]' +const confirmationVisibilityBtn = '[data-testid="confirmation-visibility-btn"]' +const expandAllBtn = '[data-testid="expande-all-btn"]' +const collapseAllBtn = '[data-testid="collapse-all-btn"]' +export const txRowTitle = '[data-testid="tx-row-title"]' +const advancedDetails = '[data-testid="tx-advanced-details"]' +const baseGas = '[data-testid="tx-base-gas"]' +const requiredConfirmation = '[data-testid="required-confirmations"]' +export const txDate = '[data-testid="tx-date"]' +export const proposalStatus = '[data-testid="proposal-status"]' +export const txSigner = '[data-testid="signer"]' +const spamTokenWarningIcon = '[data-testid="warning"]' +const untrustedTokenWarningModal = '[data-testid="untrusted-token-warning"]' +const sendTokensBtn = '[data-testid="send-tokens-btn"]' +export const replacementNewSigner = '[data-testid="new-owner"]' +export const messageItem = '[data-testid="message-item"]' +const filterStartDateInput = '[data-testid="start-date"]' +const filterEndDateInput = '[data-testid="end-date"]' +const filterAmountInput = '[data-testid="amount-input"]' +const filterTokenInput = '[data-testid="token-input"]' +const filterNonceInput = '[data-testid="nonce-input"]' +const filterApplyBtn = '[data-testid="apply-btn"]' +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 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"]' +export const altImgDai = 'img[alt="DAI"]' +export const altImgCow = 'img[alt="COW"]' +export const altImgSwaps = 'svg[alt="Swap order"]' + +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 maxAmountBtnStr = 'Max' +const nextBtnStr = 'Next' +const nativeTokenTransferStr = 'ETH' +const yesStr = 'Yes, ' +const estimatedFeeStr = 'Estimated fee' +export const executeStr = 'Execute' +const editBtnStr = 'Edit' +const executionParamsStr = 'Execution parameters' +const noLaterStr = 'No, later' +const confirmBtnStr = 'Confirm' +const expandAllBtnStr = 'Expand all' +const collapseAllBtnStr = 'Collapse all' +export const messageNestedStr = `"nestedString": "Test message 3 off-chain"` +const noTxFoundStr = (type) => `0 ${type} transactions found` +const deleteFromQueueStr = 'Delete from the queue' +const bulkExecuteBtn = (tx) => `Bulk execute ${tx} transactions` +const bulkConfirmationText = (tx) => + `This transaction batches a total of ${tx} transactions from your queue into a single Ethereum transaction` + +const disabledBultExecuteBtnTooltip = + 'Batch execution is only available for transactions that have been fully signed and are strictly sequential in Safe Account nonce' +const enabledBulkExecuteBtnTooltip = 'All highlighted transactions will be included in the batch execution' + +const bulkExecuteBtnStr = 'Bulk execute' + +const batchModalTitle = 'Batch' +export const swapOrder = 'Swap order settlement' +export const bulkTxs = 'Bulk transactions' + +export const filterTypes = { + incoming: 'Incoming', + outgoing: 'Outgoing', + module: 'Module-based', +} + +function clickOnRejectBtn() { + cy.get(rejectTxBtn).click() +} + +export function verifyBulkExecuteBtnIsEnabled(txs) { + return cy.get('button').contains(bulkExecuteBtn(txs)).should('be.enabled') +} + +export function verifyEnabledBulkExecuteBtnTooltip() { + cy.get('button').contains(bulkExecuteBtnStr).trigger('mouseover', { force: true }) + cy.contains(enabledBulkExecuteBtnTooltip).should('exist') +} + +export function deleteTx() { + clickOnRejectBtn() + cy.get(wallet.choiceBtn).contains(deleteFromQueueStr).click() + cy.get(deleteTxModalBtn).click() +} + +export function deleteAllTx() { + cy.get('body').then(($body) => { + if ($body.find(transactionItem).length > 0) { + cy.get(transactionItem).then(($items) => { + for (let i = $items.length - 1; i >= 0; i--) { + cy.wrap($items[i]).click({ force: true }) + deleteTx() + } + }) + } + }) +} + +export function setTxType(type) { + cy.get(radioSelector).find('label').contains(type).click() +} + +export function verifyNoTxDisplayed(type) { + cy.get(transactionItem) + .should('have.length', 0) + .then(($items) => { + main.verifyElementsCount($items, 0) + }) + + cy.contains(noTxFoundStr(type)).should('be.visible') +} + +export function clickOnApplyBtn() { + cy.get(filterApplyBtn).click() +} + +export function checkApplyBtnEnabled() { + cy.get(filterApplyBtn).should('not.be', 'disabled') +} + +export function clickOnClearBtn() { + cy.get(filterClearBtn).click() +} + +export function fillFilterForm({ address, startDate, endDate, amount, token, nonce, recipient } = {}) { + checkApplyBtnEnabled() + cy.wait(2000) + const inputMap = { + address: { selector: addressItem, findInput: true }, + startDate: { selector: filterStartDateInput, findInput: true }, + endDate: { selector: filterEndDateInput, findInput: true }, + amount: { selector: filterAmountInput, findInput: true }, + token: { selector: filterTokenInput, findInput: true }, + nonce: { selector: filterNonceInput, findInput: true }, + recipient: { selector: addressItem, findInput: true }, + } + + Object.entries({ address, startDate, endDate, amount, token, nonce, recipient }).forEach(([key, value]) => { + if (value !== undefined) { + const { selector, findInput } = inputMap[key] + const element = findInput ? cy.get(selector).find('input') : cy.get(selector) + element.then(($el) => { + cy.wrap($el).invoke('removeAttr', 'readonly').clear().type(value, { force: true }) + }) + } + }) +} + +export function clickOnFilterBtn() { + cy.get('button').then((buttons) => { + const filterButton = [...buttons].find((button) => { + return ['Filter', 'Incoming', 'Outgoing', 'Module-based'].includes(button.innerText) + }) + + if (filterButton) { + cy.wrap(filterButton).click() + } else { + throw new Error('No filter button found') + } + }) +} + +export function checkTxItemDate(index, date) { + cy.get(txDate).eq(index).should('contain', date) +} + +export function clickOnSendTokensBtn() { + cy.get(sendTokensBtn).click() +} +export function verifyNumberOfTransactions(count) { + cy.get(txDate).should('have.length.at.least', count) + cy.get(transactionItem).should('have.length.at.least', count) +} + +export function checkRequiredThreshold(count) { + cy.get(requiredConfirmation).should('be.visible').and('include.text', count) +} + +export function verifyAddressNotCopied(index, data) { + cy.get(copyIcon) + .parent() + .eq(index) + .trigger('click') + .wait(1000) + .then(() => + cy.window().then((win) => { + win.navigator.clipboard.readText().then((text) => { + expect(text).not.to.contain(data) + }) + }), + ) + cy.get(untrustedTokenWarningModal).should('be.visible') +} + +export function verifyWarningModalVisible() { + cy.get(untrustedTokenWarningModal).should('be.visible') +} + +export function clickOnCopyBtn(index) { + cy.get(copyIcon).parent().eq(index).trigger('click') +} + +export function verifyCopyIconWorks(index, data) { + cy.get(copyIcon) + .parent() + .eq(index) + .trigger('click') + .wait(1000) + .then(() => + cy.window().then((win) => { + win.navigator.clipboard.readText().then((text) => { + expect(text).to.contain(data) + }) + }), + ) +} + +export function verifyNumberOfCopyIcons(number) { + main.verifyElementsCount(copyIcon, number) +} + +export function verifyNumberOfExternalLinks(number) { + cy.get(copyIcon) + .parent() + .parent() + .next() + .children('a') + .then(($links) => { + expect($links.length).to.be.at.least(number) + for (let i = 0; i < number; i++) { + cy.wrap($links[i]).should('have.attr', 'href').and('include', constants.etherscanlLink) + } + }) +} + +export function clickOnTransactionItemByName(name, token) { + cy.get(transactionItem) + .filter(':contains("' + name + '")') + .then(($elements) => { + if (token) { + $elements = $elements.filter(':contains("' + token + '")') + } + cy.wrap($elements.first()).click({ force: true }) + }) +} + +export function clickOnTransactionItemByIndex(index) { + cy.get(messageItem) + .eq(index) + .then(($elements) => { + cy.wrap($elements).click({ force: true }) + }) +} + +export function verifyExpandedDetails(data, warning) { + main.checkTextsExistWithinElement(accordionDetails, data) + if (warning) cy.get(warning).should('be.visible') +} + +export function verifyTxHeaderDetails(data) { + main.checkTextsExistWithinElement(transactionItem, data) +} + +export function verifyAdvancedDetails(data) { + main.checkTextsExistWithinElement(accordionDetails, data) +} + +export function verifyActions(data) { + main.checkTextsExistWithinElement(accordionDetails, data) +} + +export function clickOnExpandableAction(data) { + cy.get(accordionDetails).within(() => { + cy.get('div').contains(data).click() + }) +} + +export function clickOnAdvancedDetails() { + cy.get(advancedDetails).click() +} + +export function expandAdvancedDetails(data) { + clickOnAdvancedDetails() + data.forEach((row) => { + cy.get(txRowTitle).contains(row).should('be.visible') + }) +} + +export function collapseAdvancedDetails() { + clickOnAdvancedDetails() + cy.get(baseGas).should('not.exist') +} + +export function expandAllActions(actions) { + cy.get(expandAllBtn).click() + main.checkTextsExistWithinElement(accordionDetails, actions) +} + +export function clickOnExpandAllActionsBtn() { + cy.get(expandAllBtn).click() +} + +export function collapseAllActions(data) { + cy.get(collapseAllBtn).click() + data.forEach((action) => { + cy.get(txRowTitle).contains(action).should('have.css', 'visibility', 'hidden') + }) +} + +export function verifyActionListExists(data) { + main.checkTextsExistWithinElement(transactionSideList, data) + main.verifyElementsIsVisible([confirmationVisibilityBtn]) +} + +export function verifySpamIconIsDisplayed(name, token) { + cy.get(transactionItem) + .filter(':contains("' + name + '")') + .filter(':contains("' + token + '")') + .then(($elements) => { + cy.wrap($elements.first()).then(($element) => { + cy.wrap($element).find(spamTokenWarningIcon).should('be.visible') + }) + }) +} + +export function verifySummaryByName(name, token, data, alt, altToken) { + if (!name) { + throw new Error('Name parameter is required for verification') + } + + let selector = `${transactionItem}:contains("${name}")` + if (token) { + selector += `:contains("${token}")` + } + + cy.get(selector).then(($elements) => { + expect($elements.length, `Transaction items found for name: ${name}`).to.be.greaterThan(0) + + const $element = $elements.first() + + if (Array.isArray(data)) { + data.forEach((text) => { + expect($element.text()).to.include(text) + }) + } else if (data) { + expect($element.text()).to.include(data) + } + + if (alt) { + const firstImg = $element.find('img') + const firstSvg = $element.find('svg') + + if (firstImg.length > 0) { + const targetImg = firstImg.first() + expect(targetImg.attr('alt')).to.equal(alt) + } else if (firstSvg.length > 0) { + const targetSvg = firstSvg.first() + expect(targetSvg.attr('alt')).to.equal(alt) + } + } + + if (altToken) { + const secondImg = $element.find('img').eq(1) + expect(secondImg.attr('alt')).to.equal(altToken) + } + }) +} +export function verifySummaryByIndex(index, data, alt) { + cy.get(messageItem) + .eq(index) + .then(($elements) => { + cy.wrap($elements).then(($element) => { + if (Array.isArray(data)) { + data.forEach((text) => { + cy.wrap($element).contains(text).should('be.visible') + }) + } else { + cy.wrap($element).contains(data).should('be.visible') + } + if (alt) cy.wrap($element).find('img').eq(0).should('have.attr', 'alt', alt).should('be.visible') + }) + }) +} + +export function clickOnTransactionItem(item) { + cy.get(transactionItem).eq(item).scrollIntoView().click({ force: true }) +} + +export function verifyTransactionActionsVisibility(option) { + cy.get(transactionSideList).should(option) +} + +export function clickOnNewtransactionBtn() { + // Assert that "New transaction" button is visible + cy.contains(newTransactionBtnStr, { + timeout: 60_000, // `lastWallet` takes a while initialize in CI + }) + .should('be.visible') + .and('not.be.disabled') + + // Open the new transaction modal + cy.contains(newTransactionBtnStr).click() + cy.contains('h1', newTransactionBtnStr).should('be.visible') +} + +export function typeRecipientAddress(address) { + cy.get(recepientInput).clear().type(address).should('have.value', address) +} +export function verifyENSResolves(fullAddress) { + let split = fullAddress.split(':') + let noPrefixAddress = split[1] + cy.get(recepientInput).should('have.value', noPrefixAddress) +} + +export function verifyRandomStringAddress(randomAddressString) { + typeRecipientAddress(randomAddressString) + cy.contains(constants.addressBookErrrMsg.invalidFormat).should('be.visible') +} + +export function verifyWrongChecksum(wronglyChecksummedAddress) { + typeRecipientAddress(wronglyChecksummedAddress) + cy.contains(constants.addressBookErrrMsg.invalidChecksum).should('be.visible') +} + +export function verifyAmountLargerThanCurrentBalance() { + setSendValue(9999) + cy.contains(constants.amountErrorMsg.largerThanCurrentBalance).should('be.visible') +} + +export function verifyTooltipMessage(message) { + cy.get('div[role="tooltip"]').contains(message).should('be.visible') +} + +export function selectCurrentWallet() { + cy.get(connectedWalletExecMethod).click() +} + +export function verifyRelayerAttemptsAvailable() { + cy.contains(transactionsPerHrStr).should('be.visible') +} + +export function clickOnTokenselectorAndSelectSepoliaEth() { + cy.get(tokenAddressInput).prev().click() + cy.get('ul[role="listbox"]').contains(constants.tokenNames.sepoliaEther).click() +} + +export function setMaxAmount() { + cy.contains(maxAmountBtnStr).click() +} + +export function verifyMaxAmount(token, tokenAbbreviation) { + cy.get(tokenAddressInput) + .prev() + .find('p') + .contains(token) + .next() + .then((element) => { + const maxBalance = parseFloat(element.text().replace(tokenAbbreviation, '').trim()) + cy.get(amountInput).should(($input) => { + const actualValue = parseFloat($input.val()) + expect(actualValue).to.be.closeTo(maxBalance, 0.1) + }) + console.log(maxBalance) + }) +} + +export function setSendValue(value) { + cy.get(amountInput).clear().type(value) +} + +export function clickOnNextBtn() { + cy.contains(nextBtnStr).click() +} + +export function verifySubmitBtnIsEnabled() { + cy.get('button[type="submit"]').should('not.be.disabled') +} + +export function verifyAddToBatchBtnIsEnabled() { + return cy.get(addToBatchBtn).should('not.be.disabled') +} + +export function verifyNativeTokenTransfer() { + cy.contains(nativeTokenTransferStr).should('be.visible') +} + +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') + + // Asserting the sponsored info is present + cy.contains(executeStr).scrollIntoView().should('be.visible') + + cy.get('span').contains(estimatedFeeStr) +} + +export function openExecutionParamsModal() { + cy.contains(estimatedFeeStr).click() + 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'] + 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.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 clickOnNoLaterOption() { + cy.contains(noLaterStr).click() +} + +export function clickOnSignTransactionBtn() { + cy.get(signBtn).click() +} + +export function clickOnConfirmTransactionBtn() { + cy.get('button').contains(confirmBtnStr).click() +} + +export function verifyConfirmTransactionBtnIsVisible() { + cy.get('button').contains(confirmBtnStr).should('be.visible') +} + +export function waitForProposeRequest() { + cy.intercept('POST', constants.proposeEndpoint).as('ProposeTx') + cy.wait('@ProposeTx') +} + +export function clickViewTransaction() { + cy.contains(viewTransactionBtn).click() +} + +export function verifySingleTxPage() { + cy.get('h3').contains(transactionDetailsTitle).should('be.visible') +} + +export function verifyQueueLabel() { + cy.contains(QueueLabel).should('be.visible') +} + +export function verifyTransactionSummary(sendValue) { + cy.contains(TransactionSummary + `${sendValue} ${constants.tokenAbbreviation.sep}`).should('exist') +} + +export function verifyDateExists(date) { + cy.contains('div', date).should('exist') +} + +export function verifyImageAltTxt(index, text) { + cy.get('img').eq(index).should('have.attr', 'alt', text).should('be.visible') +} + +export function verifyStatus(status) { + cy.contains('div', status).should('exist') +} + +export function verifyTransactionStrExists(str) { + cy.contains(str).should('exist') +} + +export function verifyTransactionStrNotVible(str) { + cy.contains(str).should('not.be.visible') +} + +export function clickOnExpandAllBtn() { + cy.contains(expandAllBtnStr).click() +} + +export function clickOnCollapseAllBtn() { + cy.contains(collapseAllBtnStr).click() +} + +export function verifyTxDestinationAddress(receivedAddress) { + cy.get(receivedAddress).then((address) => { + cy.contains(address).should('exist') + }) +} + +export function verifyReplacedSigner(newSignerName) { + cy.get(replacementNewSigner).should('exist').contains(newSignerName) +} + +function verifyBulkActions(actions) { + actions.forEach((action) => { + cy.contains(action).should('exist') + }) +} + +export function verifyBulkConfirmationScreen(tx, actions) { + cy.contains(bulkConfirmationText(tx)) + verifyBulkActions(actions) + cy.get(modal.modalHeader).within(() => { + cy.contains(batchModalTitle).should('exist') + cy.get('svg').should('exist') + }) +} + +export function verifyBulkTxHistoryBlock(order, tx, actions) { + cy.contains(order) + .parent('div') + .parent() + .eq(0) + .within(() => { + cy.contains(tx) + verifyBulkActions(actions) + }) +} + +export function verifyBulkExecuteBtnIsDisabled() { + cy.get('button').contains(bulkExecuteBtnStr).should('be.disabled') + cy.get('button').contains(bulkExecuteBtnStr).trigger('mouseover', { force: true }) + cy.contains(disabledBultExecuteBtnTooltip).should('exist') +} + +export function toggleUntrustedTxs() { + cy.get(toggleUntrustedBtn).click() +} + +export function clickOnSimulateTxBtn() { + cy.get(simulateTxBtn).click() +} + +export function verifySuccessfulSimulation() { + cy.get(simulateSuccess).should('exist') +} diff --git a/apps/web/cypress/e2e/pages/create_wallet.pages.js b/apps/web/cypress/e2e/pages/create_wallet.pages.js new file mode 100644 index 000000000..e7ae2fcfe --- /dev/null +++ b/apps/web/cypress/e2e/pages/create_wallet.pages.js @@ -0,0 +1,394 @@ +import * as main from '../pages/main.page' +import { connectedWalletExecMethod, relayExecMethod } from '../pages/create_tx.pages' +import * as sidebar from '../pages/sidebar.pages' +import * as constants from '../../support/constants' + +const welcomeLoginScreen = '[data-testid="welcome-login"]' +const expandMoreIcon = 'svg[data-testid="ExpandMoreIcon"]' +const newtworkSelectorDiv = 'div[class*="networkSelector"]' +const nameInput = 'input[name="name"]' +const ownerInput = 'input[name^="owners"][name$="name"]' +const ownerAddress = 'input[name^="owners"][name$="address"]' +const thresholdInput = 'input[name="threshold"]' +export const removeOwnerBtn = 'button[aria-label="Remove signer"]' +const connectingContainer = 'div[class*="connecting-container"]' +const createNewSafeBtn = '[data-testid="create-safe-btn"]' +const continueWithWalletBtn = 'Continue with Private key' +const googleConnectBtn = '[data-testid="google-connect-btn"]' +const googleSignedinBtn = '[data-testid="signed-in-account-btn"]' +export const accountInfoHeader = '[data-testid="open-account-center"]' +export const reviewStepOwnerInfo = '[data-testid="review-step-owner-info"]' +export const reviewStepNextBtn = '[data-testid="review-step-next-btn"]' +const creationModalLetsGoBtn = '[data-testid="cf-creation-lets-go-btn"]' +const safeCreationStatusInfo = '[data-testid="safe-status-info"]' +const startUsingSafeBtn = '[data-testid="start-using-safe-btn"]' +const sponsorIcon = '[data-testid="sponsor-icon"]' +const networkFeeSection = '[data-tetid="network-fee-section"]' +const nextBtn = '[data-testid="next-btn"]' +const backBtn = '[data-testid="back-btn"]' +const cancelBtn = '[data-testid="cancel-btn"]' +const safeActivationSection = '[data-testid="activation-section"]' +export const addressAutocompleteOptions = '[data-testid="address-item"]' +export const qrCode = '[data-testid="qr-code"]' +export const addressInfo = '[data-testid="address-info"]' +export const choiceBtn = '[data-testid="choice-btn"]' +const addFundsBtn = '[data-testid="add-funds-btn"]' +const createTxBtn = '[data-testid="create-tx-btn"]' +const qrCodeSwitch = '[data-testid="qr-code-switch"]' +export const activateAccountBtn = '[data-testid="activate-account-btn-cf"]' +export const activateFlowAccountBtn = '[data-testid="activate-account-flow-btn"]' +const notificationsSwitch = '[data-testid="notifications-switch"]' +export const addFundsSection = '[data-testid="add-funds-section"]' +export const noTokensAlert = '[data-testid="no-tokens-alert"]' +const networkCheckbox = '[data-testid="network-checkbox"]' +const cancelIcon = '[data-testid="CancelIcon"]' +const thresholdItem = '[data-testid="threshold-item"]' +export const payNowLaterMessageBox = '[data-testid="pay-now-later-message-box"]' +export const safeSetupOverview = '[data-testid="safe-setup-overview"]' +export const networksLogoList = '[data-testid="network-list"]' +export const reviewStepSafeName = '[data-testid="review-step-safe-name"]' +export const reviewStepThreshold = '[data-testid="review-step-threshold"]' +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 sponsorStr = 'Your account is sponsored by Goerli' +const safeCreationProcessing = 'Transaction is being executed' +const safeCreationComplete = 'Your Safe Account is being indexed' +const changeNetworkWarningStr = 'Change your wallet network' +const policy1_2 = '1/1 policy' +export const walletName = 'test1-sepolia-safe' +export const defaultSepoliaPlaceholder = 'Sepolia Safe' +const welcomeToSafeStr = 'Welcome to Safe' +const initialSteps = '0 of 2 steps completed' +export const addSignerStr = 'Add signer' +export const accountRecoveryStr = 'Account recovery' +export const sendTokensStr = 'Send tokens' +const noWalletConnectedMsg = 'No wallet connected' +export const deployWalletStr = 'about to deploy this Safe Account' +const showAllNetworksStr = 'Show all networks' + +export function waitForConnectionMsgDisappear() { + cy.contains(noWalletConnectedMsg).should('not.exist') +} +export function checkNotificationsSwitchIs(status) { + cy.get(notificationsSwitch).find('input').should(`be.${status}`) +} + +export function clickOnActivateAccountBtn(index) { + cy.get(activateAccountBtn).eq(index).click() +} + +export function clickOnFinalActivateAccountBtn(index) { + cy.get(activateFlowAccountBtn).click() +} + +export function clickOnQRCodeSwitch() { + cy.get(qrCodeSwitch).click() +} + +export function checkQRCodeSwitchStatus(state) { + cy.get(qrCodeSwitch).find('input').should(state) +} + +export function checkInitialStepsDisplayed() { + cy.contains(initialSteps).should('be.visible') +} + +export function clickOnAddFundsBtn() { + cy.get(addFundsBtn).click() +} + +export function clickOnCreateTxBtn() { + cy.get(createTxBtn).click() + main.verifyElementsCount(choiceBtn, 6) +} + +export function checkAllTxTypesOrder(expectedOrder) { + main.checkTextOrder(choiceBtn, expectedOrder) +} + +export function clickOnTxType(tx) { + cy.get(choiceBtn).contains(tx).click() +} + +export function verifyCFSafeCreated() { + main.verifyElementsIsVisible([sidebar.pendingActivationIcon, safeActivationSection]) +} + +export function selectPayLaterOption() { + cy.get(connectedWalletExecMethod).click() +} + +export function selectRelayOption() { + cy.get(relayExecMethod).click() +} + +export function cancelWalletCreation() { + cy.get(cancelBtn).click() + cy.get('button').contains(continueWithWalletBtn).should('be.visible') +} + +export function clickOnBackBtn() { + cy.get(backBtn).should('be.enabled').click() +} +export function verifySafeIsBeingCreated() { + cy.get(safeCreationStatusInfo).should('have.text', safeCreationProcessing) +} + +export function verifySafeCreationIsComplete() { + cy.get(safeCreationStatusInfo).should('exist').and('have.text', safeCreationComplete) + cy.get(startUsingSafeBtn).should('exist').click() + cy.get(welcomeToSafeStr).should('exist') +} + +export function clickOnReviewStepNextBtn() { + cy.get(reviewStepNextBtn).click() + cy.get(reviewStepNextBtn, { timeout: 60000 }).should('not.exist') +} + +export function clickOnLetsGoBtn() { + cy.get(creationModalLetsGoBtn).click() + return cy.get(creationModalLetsGoBtn, { timeout: 60000 }).should('not.exist') +} + +export function verifyOwnerInfoIsPresent() { + return cy.get(reviewStepOwnerInfo).shoul('exist') +} + +export function verifySponsorMessageIsPresent() { + main.verifyElementsExist([sponsorIcon, networkFeeSection]) + // Goerli is generated + cy.get(networkFeeSection).contains(sponsorStr).should('exist') +} + +export function verifyPolicy1_1() { + cy.contains(policy1_2).should('exist') + // TOD: Need data-cy for containers +} + +export function verifyDefaultWalletName(name) { + cy.get(nameInput).invoke('attr', 'placeholder').should('include', name) +} + +export function verifyNextBtnIsDisabled() { + cy.get('button').contains('Next').should('be.disabled') +} + +export function verifyNextBtnIsEnabled() { + cy.get('button').contains('Next').should('not.be.disabled') +} + +export function checkNetworkChangeWarningMsg() { + cy.get('div').contains(changeNetworkWarningStr).should('exist') +} + +export function clickOnCreateNewSafeBtn() { + cy.get(createNewSafeBtn).click().wait(1000) +} + +export function clickOnContinueWithWalletBtn() { + cy.get('button').contains(continueWithWalletBtn).click().wait(1000) +} + +export function verifyConnectWalletBtnDisplayed() { + return cy.get(connectWalletBtn).should('be.visible') +} +export function clickOnConnectWalletBtn() { + cy.get(welcomeLoginScreen).within(() => { + verifyConnectWalletBtnDisplayed().should('be.enabled').click().wait(1000) + }) +} + +export function typeWalletName(name) { + cy.get(nameInput).type(name).should('have.value', name) +} + +export function clearWalletName() { + cy.get(nameInput).clear() +} + +export function openNetworkSelector() { + cy.get(newtworkSelectorDiv).find(expandMoreIcon).parent().click() +} +export function selectNetwork(network) { + cy.wait(1000) + openNetworkSelector() + cy.wait(1000) + let regex = new RegExp(`^${network}$`) + cy.get('li').parents('ul').contains(regex).click() +} + +export function selectMultiNetwork(index, network) { + clickOnMultiNetworkInput(index) + enterNetwork(index, network) + clickOnNetwrokCheckbox() +} + +export function clickOnNetwrokCheckbox() { + cy.get(networkCheckbox).eq(0).click() +} +export function enterNetwork(index, network) { + cy.get('input').eq(index).type(network) +} +export function clickOnMultiNetworkInput(index) { + cy.get('input').eq(index).click() +} + +export function clearNetworkInput(index) { + cy.get('input').eq(index).click() + cy.get(cancelIcon).click() +} + +export function clickOnNetwrokRemoveIcon() { + cy.get(cancelIcon).click() +} + +export function clickOnNextBtn() { + cy.get(nextBtn).should('be.enabled').click() +} + +export function verifyOwnerName(name, index) { + cy.get(ownerInput).eq(index).should('have.value', name) +} + +export function verifyOwnerAddress(address, index) { + cy.get(ownerAddress).eq(index).should('have.value', address) +} + +export function verifyThreshold(number) { + cy.get(thresholdInput).should('have.value', number) +} + +export function clickOnSignerAddressInput(index) { + cy.get(getOwnerAddressInput(index)).clear() +} + +export function selectSignerOnAutocomplete(index) { + cy.wait(500) + cy.get(addressAutocompleteOptions).eq(index).click() +} + +export function typeOwnerName(name, index) { + cy.get(getOwnerNameInput(index)).type(name).should('have.value', name) +} + +export function typeOwnerAddress(address, index, clearOnly = false) { + if (clearOnly) { + cy.get(getOwnerAddressInput(index)).clear() + cy.get('body').click() + return + } + cy.get(getOwnerAddressInput(index)).clear().type(address).should('have.value', address) +} + +export function clickOnAddNewOwnerBtn() { + cy.contains('button', 'Add new signer').click().wait(700) +} + +export function addNewOwner(name, address, index) { + clickOnAddNewOwnerBtn() + typeOwnerName(name, index) + typeOwnerAddress(address, index) +} + +export function updateThreshold(number) { + cy.get(thresholdInput).parent().click() + cy.get(thresholdItem).contains(number).click() +} + +export function removeOwner(index) { + // Index for remove owner btn which does not equal to number of owners + cy.get(removeOwnerBtn).eq(index).click() +} + +export function verifySafeNameInSummaryStep(name) { + cy.contains(name) +} + +export function verifyOwnerNameInSummaryStep(name) { + cy.contains(name) +} + +export function verifyOwnerAddressInSummaryStep(address) { + cy.contains(address) +} + +export function verifyThresholdStringInSummaryStep(startThreshold, endThreshold) { + cy.contains(`${startThreshold} out of ${endThreshold}`) +} + +export function verifySafeNetworkNameInSummaryStep(name) { + cy.get('div').contains('Name').parent().parent().contains(name) +} + +export function verifyEstimatedFeeInSummaryStep() { + cy.get('b') + .contains('ETH') + .parent() + .should(($element) => { + const text = 'a' + $element.text() + const pattern = /\d/ + expect(/\d/.test(text)).to.equal(true) + }) +} + +function getOwnerNameInput(index) { + return `input[name="owners.${index}.name"]` +} + +function getOwnerAddressInput(index) { + return `input[name="owners.${index}.address"]` +} + +export function assertCFSafeThresholdAndSigners(chainId, threshold, expectedOwnersCount, lsdata) { + const localStorageData = lsdata + const data = JSON.parse(localStorageData) + let thresholdFound = false + + for (const address in data[chainId]) { + const safe = data[chainId][address] + + if (safe.props.safeAccountConfig.threshold === threshold) { + thresholdFound = true + + const ownersCount = safe.props.safeAccountConfig.owners.length + if (ownersCount !== expectedOwnersCount) { + throw new Error( + `Safe at address ${address} on chain ID ${chainId} has ${ownersCount} owners, expected ${expectedOwnersCount}.`, + ) + } + + console.log(`Safe with threshold ${threshold} and ${expectedOwnersCount} owners exists on chain ID ${chainId}.`) + break + } + } + + if (!thresholdFound) { + throw new Error(`No safe found with threshold ${threshold} on chain ID ${chainId}.`) + } +} + +function checkNetworkLogo(network) { + cy.get('img').then((logos) => { + const isLogoPresent = [...logos].some((img) => img.getAttribute('src').includes(network)) + expect(isLogoPresent).to.be.true + }) +} + +export function checkNetworkLogoInReviewStep(networks) { + cy.get(networksLogoList).within(() => { + networks.forEach((network) => { + checkNetworkLogo(network) + }) + }) +} + +export function checkNetworkLogoInSafeCreationModal(networks) { + cy.get(cfSafeInfo).within(() => { + networks.forEach((network) => { + checkNetworkLogo(network) + }) + }) +} diff --git a/cypress/e2e/pages/dashboard.pages.js b/apps/web/cypress/e2e/pages/dashboard.pages.js similarity index 83% rename from cypress/e2e/pages/dashboard.pages.js rename to apps/web/cypress/e2e/pages/dashboard.pages.js index 746106e15..ce1675e7c 100644 --- a/cypress/e2e/pages/dashboard.pages.js +++ b/apps/web/cypress/e2e/pages/dashboard.pages.js @@ -4,20 +4,16 @@ import * as main from './main.page.js' import * as createtx from './create_tx.pages.js' import staticSafes from '../../fixtures/safes/static.json' -const connectAndTransactStr = 'Connect & transact' const transactionQueueStr = 'Pending transactions' const noTransactionStr = 'This Safe has no queued transactions' const overviewStr = 'Total asset value' const sendStr = 'Send' const receiveStr = 'Receive' const viewAllStr = 'View all' -const transactionBuilderStr = 'Use Transaction Builder' const safeAppStr = 'Safe Apps' const exploreSafeApps = 'Explore Safe Apps' export const copiedAppUrl = 'share/safe-app?appUrl' -const txBuilder = 'a[href*="tx-builder"]' -const safeSpecificLink = 'a[href*="&appUrl=http"]' const copyShareBtn = '[data-testid="copy-btn-icon"]' const exploreAppsBtn = '[data-testid="explore-apps-btn"]' const viewAllLink = '[data-testid="view-all-link"][href^="/transactions/queue"]' @@ -108,10 +104,6 @@ export function verifyShareBtnWorks(index, data) { ) } -export function verifyConnectTransactStrIsVisible() { - cy.contains(connectAndTransactStr).should('be.visible') -} - export function verifyOverviewWidgetData() { // Alias for the Overview section cy.contains('div', overviewStr).parents('section').as('overviewSection') @@ -144,21 +136,6 @@ export function verifyTxQueueWidget() { }) } -export function verifyFeaturedAppsSection() { - // Alias for the featured Safe Apps section - cy.contains('h2', connectAndTransactStr).parents('section').as('featuredSafeAppsSection') - - // Tx Builder app - cy.get('@featuredSafeAppsSection').within(() => { - // Transaction Builder - cy.contains(transactionBuilderStr) - cy.get(txBuilder).should('exist') - - // Featured apps have a Safe-specific link - cy.get(safeSpecificLink).should('have.length', 1) - }) -} - export function verifySafeAppsSection() { cy.contains('h2', safeAppStr).parents('section').as('safeAppsSection') cy.get('@safeAppsSection').contains(exploreSafeApps) diff --git a/cypress/e2e/pages/import_export.pages.js b/apps/web/cypress/e2e/pages/import_export.pages.js similarity index 100% rename from cypress/e2e/pages/import_export.pages.js rename to apps/web/cypress/e2e/pages/import_export.pages.js diff --git a/cypress/e2e/pages/load_safe.pages.js b/apps/web/cypress/e2e/pages/load_safe.pages.js similarity index 99% rename from cypress/e2e/pages/load_safe.pages.js rename to apps/web/cypress/e2e/pages/load_safe.pages.js index 45626eebf..503c9608c 100644 --- a/cypress/e2e/pages/load_safe.pages.js +++ b/apps/web/cypress/e2e/pages/load_safe.pages.js @@ -210,7 +210,7 @@ export function verifyDataInReviewSection(safeName, ownerName, threshold = null, cy.findByText(ownerName).should('be.visible') if (ownerAddress !== null) cy.get(safeDataForm).contains(ownerAddress).should('be.visible') if (threshold !== null) cy.get(safeDataForm).contains(threshold).should('be.visible') - if (network !== null) cy.get(sidebar.chainLogo).eq(1).contains(network).should('be.visible') + if (network !== null) cy.get(sidebar.chainLogo).eq(0).contains(network).should('be.visible') } export function clickOnAddBtn() { diff --git a/cypress/e2e/pages/main.page.js b/apps/web/cypress/e2e/pages/main.page.js similarity index 80% rename from cypress/e2e/pages/main.page.js rename to apps/web/cypress/e2e/pages/main.page.js index 5643720ef..5a770954a 100644 --- a/cypress/e2e/pages/main.page.js +++ b/apps/web/cypress/e2e/pages/main.page.js @@ -4,6 +4,8 @@ const acceptSelection = 'Save settings' const executeStr = 'Execute' const connectedOwnerBlock = '[data-testid="open-account-center"]' export const modalDialogCloseBtn = '[data-testid="modal-dialog-close-btn"]' +const closeOutreachPopupBtn = 'button[aria-label="close outreach popup"]' + export const noRelayAttemptsError = 'Not enough relay attempts remaining' export function checkElementBackgroundColor(element, color) { @@ -19,7 +21,7 @@ export function clickOnSideMenuItem(item) { export function waitForHistoryCallToComplete() { cy.intercept('GET', constants.transactionHistoryEndpoint).as('History') - cy.wait('@History') + cy.wait('@History', { timeout: 20000 }) } export const fetchSafeData = (safeAddress) => { @@ -102,10 +104,32 @@ export const getRelayRemainingAttempts = (safeAddress) => { }) } -export function verifyNonceChange(safeAddress, expectedNonce) { - fetchCurrentNonce(safeAddress).then((newNonce) => { - expect(newNonce).to.equal(expectedNonce) - }) +export function verifyNonceChange(safeAddress, expectedNonce, retries = 30, delay = 10000) { + let attempts = 0 + + function checkNonce() { + return fetchCurrentNonce(safeAddress).then((newNonce) => { + console.log(`Attempt ${attempts + 1}: newNonce = ${newNonce}, expectedNonce = ${expectedNonce}`) + + if (newNonce === expectedNonce) { + console.log('Nonce matches the expected value') + expect(newNonce).to.equal(expectedNonce) + return + } + + attempts += 1 + if (attempts < retries) { + return new Promise((resolve) => { + setTimeout(resolve, delay) + }).then(checkNonce) + } else { + console.error(`Nonce did not change to expected value after ${retries} attempts`) + return Promise.reject(new Error(`Nonce did not change to expected value after ${retries} attempts`)) + } + }) + } + + return checkNonce() } export function checkTokenBalance(safeAddress, tokenSymbol, expectedBalance) { @@ -139,13 +163,13 @@ export function checkTokenBalanceIsNull(safeAddress, tokenSymbol) { if (targetToken === undefined) { console.log('Token is undefined as expected. Stopping polling.') return true - } else if (pollCount < 9) { + } else if (pollCount < 10) { pollCount++ console.log('Token is not undefined, retrying...') - cy.wait(1000) + cy.wait(5000) poll() } else { - throw new Error('Failed to validate token status -undefined- within the allowed polling attempts.') + throw new Error('Failed to validate token status within the allowed polling attempts.') } }) } @@ -172,6 +196,26 @@ export function acceptCookies(index = 0) { }) } +export function acceptCookies2() { + cy.wait(2000) + cy.get('body').then(($body) => { + if ($body.find('button:contains(' + acceptSelection + ')').length > 0) { + cy.contains('button', acceptSelection).click() + cy.wait(500) + } + }) +} + +export function closeOutreachPopup() { + cy.wait(1000) + cy.get('body').then(($body) => { + if ($body.find(closeOutreachPopupBtn).length > 0) { + cy.get(closeOutreachPopupBtn).click() + cy.wait(500) + } + }) +} + export function verifyOwnerConnected(prefix = 'sep:') { cy.get(connectedOwnerBlock).should('contain', prefix) } @@ -333,3 +377,22 @@ export function getIframeBody(iframe) { export const checkButtonByTextExists = (buttonText) => { cy.get('button').contains(buttonText).should('exist') } + +export function getAddedSafeAddressFromLocalStorage(chainId, index) { + return cy.window().then((win) => { + const addedSafes = win.localStorage.getItem(constants.localStorageKeys.SAFE_v2__addedSafes) + const addedSafesObj = JSON.parse(addedSafes) + const safeAddress = Object.keys(addedSafesObj[chainId])[index] + return safeAddress + }) +} + +export function changeSafeChainName(originalChain, newChain) { + return originalChain.replace(/^[^:]+:/, newChain + ':') +} + +export function getSafeAddressFromUrl(url) { + const addressPattern = /0x[a-fA-F0-9]{40}/ + const match = url.match(addressPattern) + return match ? match[0] : null +} diff --git a/cypress/e2e/pages/messages.pages.js b/apps/web/cypress/e2e/pages/messages.pages.js similarity index 100% rename from cypress/e2e/pages/messages.pages.js rename to apps/web/cypress/e2e/pages/messages.pages.js diff --git a/apps/web/cypress/e2e/pages/modals.page.js b/apps/web/cypress/e2e/pages/modals.page.js new file mode 100644 index 000000000..d66562320 --- /dev/null +++ b/apps/web/cypress/e2e/pages/modals.page.js @@ -0,0 +1,31 @@ +export const modalTitle = '[data-testid="modal-title"]' +export const modal = '[data-testid="modal-view"]' +export const modalHeader = '[data-testid="modal-header"]' +export const cardContent = '[data-testid="card-content"]' +const askMeLaterOutreachBtn = 'Ask me later' + +export const modalTitiles = { + editEntry: 'Edit entry', + deleteEntry: 'Delete entry', + dataImport: 'Data import', + confirmTx: 'Confirm transaction', + confirmMsg: 'Confirm message', +} + +export function verifyModalTitle(title) { + cy.get(modalTitle).should('contain', title) +} + +export function suspendOutreachModal() { + cy.get('button') + .contains(askMeLaterOutreachBtn) + .should(() => {}) + .then(($button) => { + if (!$button.length) { + return + } + cy.wrap($button).click() + cy.contains(askMeLaterOutreachBtn).should('not.exist') + cy.wait(500) + }) +} diff --git a/cypress/e2e/pages/modals/message_confirmation.pages.js b/apps/web/cypress/e2e/pages/modals/message_confirmation.pages.js similarity index 100% rename from cypress/e2e/pages/modals/message_confirmation.pages.js rename to apps/web/cypress/e2e/pages/modals/message_confirmation.pages.js diff --git a/apps/web/cypress/e2e/pages/modules.page.js b/apps/web/cypress/e2e/pages/modules.page.js new file mode 100644 index 000000000..d886f5987 --- /dev/null +++ b/apps/web/cypress/e2e/pages/modules.page.js @@ -0,0 +1 @@ +export const moduleRemoveIcon = '[data-testid="module-remove-btn"]' diff --git a/cypress/e2e/pages/navigation.page.js b/apps/web/cypress/e2e/pages/navigation.page.js similarity index 86% rename from cypress/e2e/pages/navigation.page.js rename to apps/web/cypress/e2e/pages/navigation.page.js index ff672497e..18971fd50 100644 --- a/cypress/e2e/pages/navigation.page.js +++ b/apps/web/cypress/e2e/pages/navigation.page.js @@ -3,7 +3,7 @@ export const setupSection = '[data-testid="setup-section"]' export const modalBackBtn = '[data-testid="modal-back-btn"]' export const newTxBtn = '[data-testid="new-tx-btn"]' const modalCloseIcon = '[data-testid="CloseIcon"]' -const expandMoreIcon = 'svg[data-testid="ExpandMoreIcon"]' +export const expandMoreIcon = 'svg[data-testid="ExpandMoreIcon"]' const sentinelStart = 'div[data-testid="sentinelStart"]' const disconnectBtnStr = 'Disconnect' @@ -16,8 +16,8 @@ export function clickOnSideNavigation(option) { cy.get(option).should('exist').click() } -export function clickOnModalCloseBtn() { - cy.get(modalCloseIcon).eq(0).trigger('click') +export function clickOnModalCloseBtn(index) { + cy.get(modalCloseIcon).eq(index).trigger('click') } export function clickOnNewTxBtn() { diff --git a/cypress/e2e/pages/nfts.pages.js b/apps/web/cypress/e2e/pages/nfts.pages.js similarity index 92% rename from cypress/e2e/pages/nfts.pages.js rename to apps/web/cypress/e2e/pages/nfts.pages.js index 42620f3b6..84b51313f 100644 --- a/cypress/e2e/pages/nfts.pages.js +++ b/apps/web/cypress/e2e/pages/nfts.pages.js @@ -18,12 +18,11 @@ const modalHeader = '[data-testid="modal-header"]' const modalSelectedNFTs = '[data-testid="selected-nfts"]' const nftItemList = '[data-testid="nft-item-list"]' const nftItemNane = '[data-testid="nft-item-name"]' -const signBtn = '[data-testid="sign-btn"]' const txDetailsSummary = '[data-testid="decoded-tx-summary"]' const txAccordionDetails = '[data-testid="decoded-tx-details"]' const accordionActionItem = '[data-testid="action-item"]' -const noneNFTSelected = '0 NFTs selected' +const noneNFTSelected = /0 NFT[s]? selected/ const sendNFTStr = 'Send NFTs' const recipientAddressStr = 'Recipient address or ENS' const selectedNFTStr = 'Selected NFTs' @@ -146,12 +145,4 @@ export function clikOnNextBtn() { export function verifyReviewModalData(NFTcount) { main.verifyElementsExist([nftItemList]) main.verifyElementsCount(nftItemNane, NFTcount) - cy.get(signBtn).should('not.be.disabled') - - if (NFTcount > 1) { - const numbersArr = Array.from({ length: NFTcount }, (_, index) => index + 1) - numbersArr.forEach((number) => { - cy.contains(number.toString()).should('be.visible') - }) - } } diff --git a/cypress/e2e/pages/owners.pages.js b/apps/web/cypress/e2e/pages/owners.pages.js similarity index 86% rename from cypress/e2e/pages/owners.pages.js rename to apps/web/cypress/e2e/pages/owners.pages.js index afde4caa6..1422cedef 100644 --- a/cypress/e2e/pages/owners.pages.js +++ b/apps/web/cypress/e2e/pages/owners.pages.js @@ -7,6 +7,7 @@ import * as addressBook from '../pages/address_book.page' const tooltipLabel = (label) => `span[aria-label="${label}"]` export const removeOwnerBtn = 'span[data-track="settings: Remove owner"] > span > button' const replaceOwnerBtn = 'span[data-track="settings: Replace owner"] > span > button' +const changeThresholdBtn = 'span[data-track="settings: Change threshold"] > button' const tooltip = 'div[role="tooltip"]' const expandMoreIcon = 'svg[data-testid="ExpandMoreIcon"]' const sentinelStart = 'div[data-testid="sentinelStart"]' @@ -16,7 +17,7 @@ const newOwnerNonceInput = 'input[name="nonce"]' const thresholdInput = 'input[name="threshold"]' const thresHoldDropDownIcon = 'svg[data-testid="ArrowDropDownIcon"]' const thresholdList = 'ul[role="listbox"]' -const thresholdDropdown = 'div[aria-haspopup="listbox"]' +const thresholdDropdown = '[data-testid="threshold-selector"]' const thresholdOption = 'li[role="option"]' const existingOwnerAddressInput = (index) => `input[name="owners.${index}.address"]` const existingOwnerNameInput = (index) => `input[name="owners.${index}.name"]` @@ -26,6 +27,7 @@ const addOwnerBtn = '[data-testid="add-owner-btn"]' 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 disconnectBtnStr = 'Disconnect' const notConnectedStatus = 'Connect' @@ -37,6 +39,7 @@ const removeOwnerStr = 'Remove signer' const selectedOwnerStr = 'Selected signer' const addNewOwnerStr = 'Add new signer' const processedTransactionStr = 'Transaction was successful' +const changeThresholdStr = 'Change threshold' export const safeAccountNonceStr = 'Safe Account nonce' export const nonOwnerErrorMsg = 'Your connected wallet is not a signer of this Safe Account' @@ -64,6 +67,10 @@ export function verifyExistingOwnerAddress(index, address) { cy.get(existingOwnerAddressInput(index)).should('have.value', address) } +export function typeOwnerAddressCreateSafeStep(index, address) { + cy.get(existingOwnerAddressInput(index)).clear().type(address) +} + export function verifyExistingOwnerName(index, name) { cy.get(existingOwnerNameInput(index)).should('have.value', name) } @@ -79,8 +86,8 @@ export function verifyOwnerDeletionWindowDisplayed() { cy.get('p').contains(selectedOwnerStr) } -function clickOnThresholdDropdown() { - cy.get(thresholdDropdown).eq(1).click() +export function clickOnThresholdDropdown() { + cy.get(thresholdDropdown).eq(0).click() } export function getThresholdOptions() { @@ -88,7 +95,7 @@ export function getThresholdOptions() { } export function verifyThresholdLimit(startValue, endValue) { - cy.get('p').contains(`out of ${endValue} signer(s)`) + cy.get('p').contains(`out of ${endValue} signer${endValue > 1 ? 's' : ''}`) clickOnThresholdDropdown() getThresholdOptions().eq(0).should('have.text', startValue).click() } @@ -125,8 +132,8 @@ export function getAddressToBeRemoved() { return removedAddress } -export function openReplaceOwnerWindow() { - cy.get(replaceOwnerBtn).click({ force: true }) +export function openReplaceOwnerWindow(index) { + cy.get(replaceOwnerBtn).eq(index).click({ force: true }) cy.get(newOwnerName).should('be.visible') cy.get(newOwnerAddress).should('be.visible') } @@ -179,8 +186,11 @@ export function waitForConnectionStatus() { cy.get(createWallet.accountInfoHeader).should('exist') } -export function openAddOwnerWindow() { +export function clickOnAddSignerBtn() { cy.get(addOwnerBtn).should('be.enabled').click() +} +export function openAddOwnerWindow() { + clickOnAddSignerBtn() cy.get(newOwnerName).should('be.visible') cy.get(newOwnerAddress).should('be.visible') } @@ -191,7 +201,7 @@ export function verifyNonceInputValue(value) { } export function verifyErrorMsgInvalidAddress(errorMsg) { - cy.get('label').contains(errorMsg).should('be.visible') + cy.get('label').contains(errorMsg).should('exist') } export function verifyValidWalletName(errorMsg) { @@ -238,9 +248,22 @@ export function verifyConfirmTransactionWindowDisplayed() { export function verifyThreshold(startValue, endValue) { main.verifyInputValue(thresholdInput, startValue) - cy.get('p').contains(`out of ${endValue} signer(s)`).should('be.visible') + cy.get('p') + .contains(`out of ${endValue} signer${endValue > 1 ? 's' : ''}`) + .should('be.visible') cy.get(thresholdInput).parent().click() cy.get(thresholdList).contains(endValue).should('be.visible') cy.get(thresholdList).find('li').should('have.length', endValue) cy.get('body').click(0, 0) } + +export function clickOnChangeThresholdBtn() { + cy.get(changeThresholdBtn).click({ force: true }) + cy.get('div').contains(changeThresholdStr).should('exist') +} + +export function clickOnThresholdNextBtn() { + //TODO: Remove extra wait when init sdk is merged + cy.wait(3000) + cy.get(thresholdNextBtn).click() +} diff --git a/apps/web/cypress/e2e/pages/proposers.pages.js b/apps/web/cypress/e2e/pages/proposers.pages.js new file mode 100644 index 000000000..11411278c --- /dev/null +++ b/apps/web/cypress/e2e/pages/proposers.pages.js @@ -0,0 +1,149 @@ +import * as main from './main.page' +import * as addressBook from './address_book.page' +import * as batch from './batches.pages' +import * as create_tx from './create_tx.pages' + +export const proposersSection = '[data-testid="proposer-section"]' +const addProposerBtn = '[data-testid="add-proposer-btn"]' + +const deleteProposerBtn = '[data-testid="delete-proposer-btn"]' +const editProposerBtn = '[data-testid="edit-proposer-btn"]' +const confrimDeleteProposerBtn = '[data-testid="confirm-delete-proposer-btn"]' +const rejectDeleteProposerBtn = '[data-testid="reject-delete-proposer-btn"]' +const submitProposerBtn = '[data-testid="submit-proposer-btn"]' + +const safeAsProposerMessage = 'Cannot add Safe Account itself as proposer' +const proposedTxMessage = + 'This transaction was created by a Proposer. Please review and either confirm or reject it. Once confirmed, it can be finalized and executed' +const proposerAddedMsg = 'Proposer added successfully!' + +export function verifyPropsalStatusExists() { + cy.get(create_tx.proposalStatus).should('exist') +} + +export function verifyProposerInTxActionList(address) { + cy.get(create_tx.txSigner).within(() => { + cy.contains(address) + cy.get('div[style]') + .filter((index, element) => { + return element.style.backgroundImage.includes('url') + }) + .should('exist') + }) +} +export function verifyProposedTxMsgVisible() { + cy.contains(proposedTxMessage).should('be.visible') +} + +export function clickOnAddProposerBtn() { + cy.get(addProposerBtn).click() +} + +export function enterProposerName(name) { + addressBook.typeInNameInput(name) +} +export function enterProposerData(address, name) { + addressBook.typeInAddress(address) + enterProposerName(name) +} + +export function clickOnSubmitProposerBtn() { + cy.get(submitProposerBtn).click() +} + +export function checkCreatorAddress(data) { + cy.get(proposersSection).within(() => { + Object.entries(data).forEach(([key, value]) => { + let found = false + cy.get(addressBook.tableRow) + .each(($row) => { + cy.wrap($row) + .find('td') + .eq(1) + .then(($cell) => { + if ($cell.text().includes(value)) { + found = true + } + }) + }) + .then(() => { + expect(found, `Value "${value}" should be found in td:eq(1) within proposersSection`).to.be.true + }) + }) + }) +} + +export function checkProposerData(data) { + cy.get(proposersSection).within(() => { + Object.entries(data).forEach(([key, value]) => { + let found = false + + cy.get(addressBook.tableRow) + .each(($row) => { + cy.wrap($row) + .find('td') + .eq(0) + .then(($cell) => { + if ($cell.text().includes(value)) { + found = true + } + }) + }) + .then(() => { + expect(found, `Value "${value}" should be found in td:eq(0) within proposersSection`).to.be.true + }) + }) + }) +} + +export function clickOnEditProposerBtn(address) { + cy.get(proposersSection).within(() => { + cy.get(addressBook.tableRow).contains(address).parents('tr').find(editProposerBtn).click() + }) +} + +export function confirmProposerDeletion(index) { + cy.get(confrimDeleteProposerBtn).eq(index).click() +} + +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) + } + }) + } + main.verifyElementsCount(deleteProposerBtn, 0) + }) +} + +export function verifyAddProposerBtnIsDisabled() { + cy.get(addProposerBtn).should('exist').and('be.disabled') +} + +export function checkSafeAsProposerErrorMessage() { + cy.contains('label', safeAsProposerMessage).should('exist') +} + +export function verifyBatchDoesNotExist() { + main.verifyElementsCount(batch.batchTxTopBar, 0) +} + +export function verifyProposerSuccessMsgDisplayed() { + cy.contains(proposerAddedMsg).should('exist') +} + +export function verifyEditProposerBtnDisabled(address) { + cy.get(proposersSection).within(() => { + cy.get(addressBook.tableRow).contains(address).parents('tr').find(editProposerBtn).should('be.disabled') + }) +} + +export function verifyDeleteProposerBtnIsDisabled(address) { + cy.get(proposersSection).within(() => { + cy.get(addressBook.tableRow).contains(address).parents('tr').find(deleteProposerBtn).should('be.disabled') + }) +} diff --git a/apps/web/cypress/e2e/pages/recovery.pages.js b/apps/web/cypress/e2e/pages/recovery.pages.js new file mode 100644 index 000000000..48887605a --- /dev/null +++ b/apps/web/cypress/e2e/pages/recovery.pages.js @@ -0,0 +1,203 @@ +import * as constants from '../../support/constants' +import * as main from './main.page' +import * as safe from '../pages/load_safe.pages' +import * as tx from '../pages/transactions.page' +import { tableContainer } from '../pages/address_book.page' +import { txDate } from '../pages/create_tx.pages' +import { modalHeader } from '../pages/modals.page' +import { moduleRemoveIcon } from '../pages/modules.page' + +export const setupRecoveryBtn = '[data-testid="setup-recovery-btn"]' +export const setupRecoveryModalBtn = '[data-testid="setup-btn"]' +const recoveryNextBtn = '[data-testid="next-btn"]' +const warningSection = '[data-testid="warning-section"]' +const termsCheckbox = 'input[type="checkbox"]' +export const removeRecovererBtn = '[data-testid="remove-recoverer-btn"]' +export const editRecovererBtn = '[data-testid="edit-recoverer-btn"]' +const removeRecovererSection = '[data-testid="remove-recoverer-section"]' +const startRecoveryBtn = '[data-testid="start-recovery-btn"]' +const recoveryDelaySelect = '[data-testid="recovery-delay-select"]' +const recoveryExpirySelect = '[data-testid="recovery-expiry-select"]' +const postponeRecoveryBtn = '[data-testid="postpone-recovery-btn"]' +const goToQueueBtn = '[data-testid="queue-btn"]' +const executeBtn = '[data-testid="execute-btn"]' +const cancelRecoveryBtn = '[data-testid="cancel-recovery-btn"]' +const cancelProposalBtn = '[data-testid="cancel-proposal-btn"]' +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?' + +export const recoveryOptions = { + customPeriod: 'Custom period', + oneMin: '1 minute', + fiveMin: '5 minutes', + oneHr: '1 hour', + twoDays: '2 days', + sevenDays: '7 days', + fourteenDays: '14 days', + twentyEightDays: '28 days', + fiveSixDays: '56 days', + never: 'never', +} +export function clickOnEditRecoverer() { + cy.get(editRecovererBtn).click() +} +export function verifyRecovererSettings(data) { + main.checkTextsExistWithinElement(tableContainer, data) +} + +export function verifyRecovererConfirmationData(data) { + data.forEach((item) => { + cy.get(modalHeader).next('div').contains(item) + }) +} + +export function verifyRecoveryTableDisplayed() { + cy.get(tableContainer).should('be.visible') +} +export function clickOnExecuteRecoveryCancelBtn() { + cy.get(executeFormBtn).click() +} +export function cancelRecoveryTx() { + cy.get(txDate).click() + cy.get(cancelRecoveryBtn).scrollIntoView().click() + cy.get(cancelProposalBtn).scrollIntoView().click() +} +export function clickOnRecoveryExecuteBtn() { + cy.get(executeBtn).eq(0).should('be.enabled', { timeout: 300000 }) + cy.wait(1000) + cy.get(executeBtn).eq(0).click() +} +export function verifyTxNotInQueue() { + cy.get(txDate).should('have.length', 0) +} +export const recoveryDelayOptions = { + one_minute: '1 minute', +} + +export function setRecoveryDelay(option) { + cy.get(recoveryDelaySelect).click() + cy.contains(option).click() +} + +export function verifyRecoveryDelayOptions(options) { + cy.get(recoveryDelaySelect).click() + options.forEach((item) => { + cy.contains(item) + }) +} + +export function setRecoveryExpiry(option) { + cy.get(advancedBtn).click() + cy.get(recoveryExpirySelect).click() + cy.contains(option).click() +} + +export function verifyRecoveryExpiryOptions(options) { + cy.get(advancedBtn).click() + cy.get(recoveryExpirySelect).click() + options.forEach((item) => { + cy.contains(item) + }) +} + +export function getSetupRecoveryBtn() { + return cy.get(setupRecoveryBtn) +} + +export function clickOnSetupRecoveryBtn() { + getSetupRecoveryBtn().click() +} + +export function clickOnSetupRecoveryModalBtn() { + cy.get(setupRecoveryModalBtn).click() +} + +export function clickOnNextBtn() { + cy.get(recoveryNextBtn).click() +} + +export function clickOnGoToQueueBtn() { + cy.get(goToQueueBtn).click() + cy.get(goToQueueBtn).should('not.exist') +} + +export function enterRecovererAddress(address) { + safe.inputOwnerAddress(0, address) +} + +export function agreeToTerms() { + cy.get(warningSection).within(() => { + main.verifyCheckboxeState(termsCheckbox, 0, constants.checkboxStates.unchecked) + cy.get(termsCheckbox).click() + main.verifyCheckboxeState(termsCheckbox, 0, constants.checkboxStates.checked) + }) +} + +export function verifyRecovererAdded(address) { + main.verifyValuesExist(tableContainer, address) +} + +export function clearRecoverers() { + cy.get('body').then(($body) => { + if ($body.find(removeRecovererBtn).length) { + cy.get(removeRecovererBtn).each(($btn) => { + cy.wrap($btn).click() + clickOnNextBtn() + tx.executeFlow_1() + }) + } + }) +} + +export function clickOnStartRecoveryBtn() { + cy.get(startRecoveryBtn).click() +} + +export function enterOwnerAddress(address) { + safe.inputOwnerAddress(0, address) +} + +export function postponeRecovery() { + cy.wait(7000) + cy.get(postponeRecoveryBtn) + .should(() => {}) + .then(($button) => { + if (!$button.length) { + return + } + cy.wrap($button).click() + cy.get(postponeRecoveryBtn).should('not.exist') + }) +} + +export function clickOnRecoverLaterBtn() { + cy.get(postponeRecoveryBtn).click() + cy.get(postponeRecoveryBtn).should('not.exist') +} + +export function verifyNonceState(state) { + if (state === constants.elementExistanceStates.exist) { + cy.get(nonceFld).should(constants.elementExistanceStates.exist) + } + cy.get(nonceFld).should(constants.elementExistanceStates.not_exist) +} + +export function verifyRecoveryProposalModalState(option, horizontal = false) { + let modal = recoveryProposalModal + if (horizontal) modal = recoveryProposalHorizontal + cy.get(modal).should(option) +} + +export function verifyRecoveryModalDisplayed() { + cy.contains(recoveryModalTitle).should('be.visible') +} + +export function deleteRecoveryModule() { + cy.get(moduleRemoveIcon).click() + main.acceptCookies() + clickOnNextBtn() + tx.executeFlow_1() +} diff --git a/cypress/e2e/pages/safeapps.pages.js b/apps/web/cypress/e2e/pages/safeapps.pages.js similarity index 76% rename from cypress/e2e/pages/safeapps.pages.js rename to apps/web/cypress/e2e/pages/safeapps.pages.js index 1810656cb..f30fed55e 100644 --- a/cypress/e2e/pages/safeapps.pages.js +++ b/apps/web/cypress/e2e/pages/safeapps.pages.js @@ -10,6 +10,7 @@ export const deleteBatchBtn = 'button[title="Delete Batch"]' 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 addBtnStr = /add/i const noAppsStr = /no Safe Apps found/i @@ -27,7 +28,7 @@ const acceptBtnStr = /accept/i const clearAllBtnStr = /clear all/i const allowAllPermissions = /allow all/i export const enterAddressStr = /enter address or ens name/i -export const addTransactionStr = /add transaction/i +export const addTransactionStr = /add new transaction/i export const createBatchStr = /create batch/i export const sendBatchStr = /send batch/i export const transactionDetailsStr = /transaction details/i @@ -52,6 +53,7 @@ export const selectAllRowsChbxStr = /Select All Rows checkbox/i export const selectRowChbxStr = /Select Row checkbox/i export const recipientStr = /recipient/i export const validRecipientAddressStr = /please enter a valid recipient address/i +export const contractMethodSelector = 'input[id="contract-method-selector"]' export const testAddressValue2 = 'testAddressValue' export const testBooleanValue = 'testBooleanValue' export const testFallback = 'fallback' @@ -62,7 +64,7 @@ export const testBooleanValue3 = '3 testBooleanValue' export const transfer2AssetsStr = 'Transfer 2 assets' export const testTransfer1 = '1 transfer' -export const testTransfer2 = '2 transfer' +export const testTransfer2 = '2 MetaMultiSigWallet: transfer' export const nativeTransfer2 = '2 native transfer' export const nativeTransfer1 = '1 native transfer' @@ -89,11 +91,16 @@ export const warningStr = 'Warning' export const transferStr = 'Transfer' export const successStr = 'Success' export const failedStr = 'Failed' +const blindSigningStr = 'This request involves blind signing' +const enableBlindSigningStr = 'Enable blind signing' +const blindSigningStr2 = 'blind signing' +const signBtnStr = 'Sign' export const dummyTxStr = 'Trigger dummy tx (safe.txs.send)' export const signOnchainMsgStr = 'Sign message (on-chain)' export const pinWalletConnectStr = /pin walletconnect/i export const transactionBuilderStr = 'Transaction Builder' +export const cowswapStr = 'CowSwap' export const testAddressValueStr = 'testAddressValue' export const logoWalletConnect = /logo.*walletconnect/i export const walletConnectHeadlinePreview = /walletconnect/i @@ -103,6 +110,7 @@ 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' @@ -110,8 +118,12 @@ export const linkNames = { wcLogo: /WalletConnect logo/i, txBuilderLogo: /Transaction Builder logo/i, } +const featuredAppsStr = /featured apps/i +const pinnedAppsStr = 'My pinned apps' +const pinnedAppsStrR = /my pinned apps/i + export const abi = - '[{{}"inputs":[{{}"internalType":"address","name":"_singleton","type":"address"{}}],"stateMutability":"nonpayable","type":"constructor"{}},{{}"stateMutability":"payable","type":"fallback"{}}]' + '[{"inputs":[{"internalType":"address","name":"_singleton","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"stateMutability":"payable","type":"fallback"},{"inputs":[{"internalType":"address","name":"target","type":"address"}],"name":"AddressEmptyCode","type":"error"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"}]' export const permissionCheckboxes = { camera: 'input[name="camera"]', @@ -133,6 +145,31 @@ export function triggetOffChainTx() { cy.contains(dummyTxStr).click() } +export function verifyBlindSigningEnabled(option) { + if (option) { + cy.contains(blindSigningStr).should('be.visible') + } else { + cy.contains(blindSigningStr).should('not.exist') + } +} + +export function clickOnBlindSigningOption() { + cy.contains(blindSigningStr2).click() + cy.contains(enableBlindSigningStr).click() +} + +export function triggetSignMsg() { + cy.contains(signOnchainMsgStr).click() +} + +export function enterMessage(msg) { + cy.get(appMessageInput).type(msg) +} + +export function verifySignBtnDisabled() { + cy.get('button').contains(signBtnStr).should('be.disabled') +} + export function triggetOnChainTx() { cy.contains(signOnchainMsgStr).click() } @@ -163,9 +200,11 @@ export function verifyNoAppsTextPresent() { cy.contains(noAppsStr).should('exist') } -export function pinApp(app, pin = true) { +export function pinApp(index, app, pin = true) { const option = pin ? 'Pin' : 'Unpin' - cy.get(`[aria-label="${option} ${app}"]`).click() + const option_ = pin ? 'Unpin' : 'Pin' + cy.get(`[aria-label="${option} ${app}"]`).eq(index).click() + cy.get(`[aria-label="${option_} ${app}"]`).should('exist') } export function clickOnBookmarkedAppsTab() { @@ -181,7 +220,23 @@ export function verifyCustomAppCount(count) { } export function verifyPinnedAppCount(count) { - cy.findByText(`My pinned apps (${count})`).should(count ? 'be.visible' : 'not.exist') + cy.findByText(`${pinnedAppsStr} (${count})`).should(count ? 'be.visible' : 'not.exist') +} + +export function verifyAppInFeaturedList(app) { + cy.findByText(featuredAppsStr) + .next('ul') + .within(() => { + cy.findByText(app).should('exist') + }) +} + +export function verifyAppInPinnedList(app) { + cy.findByText(pinnedAppsStrR) + .next('ul') + .within(() => { + cy.findByText(app).should('exist') + }) } export function clickOnCustomAppsTab() { @@ -218,12 +273,10 @@ export function verifyAppDescription(descr) { export function clickOnOpenSafeAppBtn() { cy.get(openSafeAppBtn).click() - cy.wait(2000) } export function verifyDisclaimerIsDisplayed() { verifyDisclaimerIsVisible() - cy.wait(500) } function verifyDisclaimerIsVisible() { @@ -249,20 +302,17 @@ export function verifyMicrofoneCheckBoxExists() { return cy.findByRole('checkbox', { name: microfoneCheckBoxStr }).should('exist') } -export function storeAndVerifyPermissions() { +export function verifyInfoModalAcceptance() { cy.waitForSelector(() => { return cy .findByRole('button', { name: continueBtnStr }) .click() - .wait(500) + .wait(2000) .should(() => { - const storedBrowserPermissions = JSON.parse(localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY)) - const browserPermissions = Object.values(storedBrowserPermissions)[0][0] - const storedInfoModal = JSON.parse(localStorage.getItem(constants.INFO_MODAL_KEY)) - - expect(browserPermissions.feature).to.eq('camera') - expect(browserPermissions.status).to.eq('granted') - expect(storedInfoModal['11155111'].consentsAccepted).to.eq(true) + const storedInfoModal = JSON.parse( + localStorage.getItem(constants.localStorageKeys.SAFE_v2__SafeApps__infoModal), + ) + expect(storedInfoModal[constants.networkKeys.sepolia].consentsAccepted).to.eq(true) }) }) } diff --git a/apps/web/cypress/e2e/pages/sidebar.pages.js b/apps/web/cypress/e2e/pages/sidebar.pages.js new file mode 100644 index 000000000..57db9fc32 --- /dev/null +++ b/apps/web/cypress/e2e/pages/sidebar.pages.js @@ -0,0 +1,613 @@ +import * as constants from '../../support/constants.js' +import * as main from './main.page.js' +import * as modal from './modals.page.js' +import * as navigation from './navigation.page.js' +import { safeHeaderInfo } from './import_export.pages.js' +import * as file from './import_export.pages.js' +import safes from '../../fixtures/safes/static.json' +import * as address_book from './address_book.page.js' +import * as create_wallet from '../pages/create_wallet.pages.js' + +export const chainLogo = '[data-testid="chain-logo"]' +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"]' +const explorerBtn = '[data-testid="explorer-btn"]' +export const sideBarListItem = '[data-testid="sidebar-list-item"]' +const sideBarListItemWhatsNew = '[data-testid="list-item-whats-new"]' +const sideBarListItemNeedHelp = '[data-testid="list-item-need-help"]' +const sideSafeListItem = '[data-testid="safe-list-item"]' +const sidebarSafeHeader = '[data-testid="safe-header-info"]' +const sidebarSafeContainer = '[data-testid="sidebar-safe-container"]' +const safeItemOptionsBtn = '[data-testid="safe-options-btn"]' +export const safeItemOptionsRenameBtn = '[data-testid="rename-btn"]' +export const safeItemOptionsRemoveBtn = '[data-testid="remove-btn"]' +export const safeItemOptionsAddChainBtn = '[data-testid="add-chain-btn"]' +const nameInput = '[data-testid="name-input"]' +const saveBtn = '[data-testid="save-btn"]' +const deleteBtn = '[data-testid="delete-btn"]' +const readOnlyVisibility = '[data-testid="read-only-visibility"]' +const currencySection = '[data-testid="currency-section"]' +const missingSignatureInfo = '[data-testid="missing-signature-info"]' +const queuedTxInfo = '[data-testid="queued-tx-info"]' +const expandSafesList = '[data-testid="expand-safes-list"]' +export const importBtn = '[data-testid="import-btn"]' +export const pendingActivationIcon = '[data-testid="pending-activation-icon"]' +const safeItemMenuIcon = '[data-testid="MoreVertIcon"]' +const multichainItemSummary = '[data-testid="multichain-item-summary"]' +const addChainDialog = "[data-testid='add-chain-dialog']" +export const addNetworkBtn = "[data-testid='add-network-btn']" +const modalAddNetworkBtn = "[data-testid='modal-add-network-btn']" +const subAccountContainer = '[data-testid="subacounts-container"]' +const groupBalance = '[data-testid="group-balance"]' +const groupAddress = '[data-testid="group-address"]' +const groupSafeIcon = '[data-testid="group-safe-icon"]' +const multichainTooltip = '[data-testid="multichain-tooltip"]' +const networkInput = '[id="network-input"]' +const networkOption = 'li[role="option"]' +const showAllNetworks = '[data-testid="show-all-networks"]' +const showAllNetworksStr = 'Show all networks' +export const addNetworkOption = 'li[aria-label="Add network"]' +export const addedNetworkOption = 'li[role="option"]' +const modalAddNetworkName = '[data-testid="added-network"]' +const networkSeperator = 'div[role="separator"]' +export const addNetworkTooltip = '[data-testid="add-network-tooltip"]' +const pinnedAccountsContainer = '[data-testid="pinned-accounts"]' +const emptyPinnedList = '[data-testid="empty-pinned-list"]' +const boomarkIcon = '[data-testid="bookmark-icon"]' +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 importBtnStr = 'Import' +export const exportBtnStr = 'Export' +export const undeployedSafe = 'Undeployed Sepolia' +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' +const signersNotConsistentMsg2 = (network) => `Signers are different on these networks of this account:${network}` +const signersNotConsistentMsg3 = + 'To manage your account easier and to prevent lose of funds, we recommend keeping the same signers' +const signersNotConsistentConfirmTxViewMsg = (network) => + `Signers are not consistent across networks on this account. Changing signers will only affect the account on ${network}` +const activateStr = 'You need to activate your Safe first' +const emptyPinnedMessage = 'Personalize your account list by clicking theicon on the accounts most important to you.' + +export const addedSafesEth = ['0x8675...a19b'] +export const addedSafesSepolia = ['0x6d0b...6dC1', '0x5912...fFdb', '0x0637...708e', '0xD157...DE9a'] +export const sideBarListItems = ['Home', 'Assets', 'Transactions', 'Address book', 'Apps', 'Settings', 'Swap'] +export const sideBarSafes = { + safe1: '0xBb26E3717172d5000F87DeFd391994f789D80aEB', + safe2: '0x905934aA8758c06B2422F0C90D97d2fbb6677811', + safe3: '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B', + safe1short: '0xBb26...0aEB', + safe1short_: '0xBb26', + safe2short: '0x9059...7811', + safe3short: '0x86Cb...2C27', + safe4short: '0x9261...7E00', + multichain_short_: '0xC96e', +} + +// 0x926186108f74dB20BFeb2b6c888E523C78cb7E00 +export const sideBarSafesPendingActions = { + safe1: '0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb', + safe1short: '0x5912...fFdb', +} +export const testSafeHeaderDetails = ['2/2', safes.SEP_STATIC_SAFE_9_SHORT] +const receiveAssetsStr = 'Receive assets' +const emptyPinnedListStr = 'Watch any Safe Account to keep an eye on its activity' +const emptySafeListStr = "You don't have any safes yet" +const accountsRegex = /(My accounts|Accounts) \((\d+)\)/ +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'}` + +export const sortOptions = { + lastVisited: '[data-testid="last-visited-option"]', + name: '[data-testid="name-option"]', +} +export function checkSearchResults(number) { + cy.contains(searchResults(number)).should('exist') +} + +export const multichainSafes = { + polygon: 'Multichain polygon', + sepolia: 'Multichain Sepolia', +} + +export function searchSafe(safe) { + cy.get(searchInput).clear().type(safe) +} + +export function openSortOptionsMenu() { + cy.get(sortbyBtn).click() +} + +export function selectSortOption(option) { + cy.get(option).click() +} + +export function clearSearchInput() { + cy.get(searchInput).scrollIntoView().clear({ force: true }) +} + +export function verifySearchInputPosition() { + cy.get(searchInput).then(($searchInput) => { + cy.get(pinnedAccountsContainer).then(($pinnedList) => { + const searchInputPosition = $searchInput[0].compareDocumentPosition($pinnedList[0]) + expect(searchInputPosition & Node.DOCUMENT_POSITION_FOLLOWING).to.equal(Node.DOCUMENT_POSITION_FOLLOWING) + }) + }) +} + +export function verifyNumberOfPendingTxTag(tx) { + cy.get(pinnedAccountsContainer).within(() => { + cy.get('span').contains(pedningTxStr(tx)) + }) +} + +export function verifyPinnedSafe(safe) { + cy.get(pinnedAccountsContainer).within(() => { + cy.get(sideSafeListItem).contains(safe) + }) +} + +export function getImportBtn() { + return cy.get(importBtn).scrollIntoView().should('be.visible') +} +export function clickOnSidebarImportBtn() { + getImportBtn().click() + modal.verifyModalTitle(modal.modalTitiles.dataImport) + file.verifyValidImportInputExists() +} + +export function showAllSafes() { + cy.wait(500) + cy.get('body').then(($body) => { + if ($body.find(expandSafesList).length > 0) { + cy.get(expandSafesList).click() + cy.wait(500) + } + }) +} + +export function verifyAccountsCollapsed() { + cy.get(expandSafesList).should('have.attr', 'aria-expanded', 'false') +} + +export function verifyConnectBtnDisplayed() { + cy.get(emptyAccountList).within(() => { + create_wallet.verifyConnectWalletBtnDisplayed() + }) +} + +export function verifyNetworkIsDisplayed(netwrok) { + cy.get(sidebarContainer) + .should('be.visible') + .within(() => { + cy.get(chainLogo).should('contain', netwrok) + }) +} + +export function verifySafeHeaderDetails(details) { + main.checkTextsExistWithinElement(safeHeaderInfo, details) + main.verifyElementsExist([safeIcon, currencySection]) +} + +export function clickOnQRCodeBtn() { + cy.get(sidebarContainer) + .should('be.visible') + .within(() => { + cy.get(qrModalBtn).click() + }) +} + +export function verifyQRModalDisplayed() { + cy.get(modal.modal).should('be.visible') + cy.get(modal.modalTitle).should('contain', receiveAssetsStr) +} + +export function verifyCopyAddressBtn(data) { + cy.wait(1000) + cy.get(sidebarContainer) + .should('be.visible') + .within(() => { + cy.get(copyAddressBtn) + .click() + .wait(1000) + .then(() => + cy.window().then((win) => { + win.navigator.clipboard.readText().then((text) => { + expect(text).to.contain(data) + }) + }), + ) + }) +} + +export function verifyEtherscanLinkExists() { + main.verifyMinimumElementsCount(explorerBtn, 1) + cy.get(sidebarContainer) + .should('be.visible') + .within(() => { + cy.get(explorerBtn).should('have.attr', 'href').and('include', constants.etherscanlLink) + }) +} + +export function verifyNewTxBtnStatus(status) { + main.verifyElementsStatus([navigation.newTxBtn], status) +} + +export function verifySideListItems() { + main.verifyValuesExist(sideBarListItem, sideBarListItems) + main.verifyElementsExist([sideBarListItemWhatsNew, sideBarListItemNeedHelp]) +} + +export function verifyTxCounter(counter) { + cy.get(sideBarListItem).contains(sideBarListItems[2]).should('contain', counter) +} + +export function verifyNavItemDisabled(item) { + cy.get(`div[aria-label*="${activateStr}"]`).contains(item).should('exist') +} + +export function verifySafeCount(count) { + main.verifyMinimumElementsCount(sideSafeListItem, count) +} + +export function verifyAccountListSafeCount(count) { + cy.get(accountsList).within(() => { + cy.get(sideSafeListItem).should('have.length', count) + }) +} + +export function clickOnOpenSidebarBtn() { + cy.get(openSafesIcon).click() +} + +// Expands all safes in the sidebar +export function openSidebar() { + clickOnOpenSidebarBtn() + cy.wait(500) + showAllSafes() + main.verifyElementsExist([sidebarSafeContainer]) +} + +export function verifyAddedSafesExist(safes) { + main.verifyValuesExist(sideSafeListItem, safes) +} + +export function verifySafesDoNotExist(safes) { + main.verifyValuesDoNotExist(sidebarSafeContainer, safes) +} + +export function verifyAddedSafesExistByIndex(index, safe) { + cy.get(sideSafeListItem).eq(index).should('contain', safe) +} + +export function verifySafesByNetwork(netwrok, safes) { + cy.get(sidebarSafeContainer).within(() => { + cy.get(chainLogo) + .contains(netwrok) + .parent() + .next() + .within(() => { + main.verifyValuesExist(sideSafeListItem, safes) + }) + }) +} + +function getSafeByName(safe) { + return cy.get(sidebarSafeContainer).find(sideSafeListItem).contains(safe).parents('span').parent().should('exist') +} + +function getSafeItemOptions(name) { + return getSafeByName(name).within(() => { + cy.get(safeItemOptionsBtn) + }) +} + +export function verifySafeReadOnlyState(safe) { + getSafeItemOptions(safe).find(readOnlyVisibility).should('exist') +} + +export function verifyMissingSignature(safe) { + getSafeItemOptions(safe).find(missingSignatureInfo).should('exist') +} + +export function verifyQueuedTx(safe) { + return getSafeItemOptions(safe).find(queuedTxInfo).should('exist') +} + +export function clickOnSafeItemOptionsBtn(name) { + getSafeItemOptions(name).find(safeItemOptionsBtn).click() +} + +export function clickOnSafeItemOptionsBtnByIndex(index) { + cy.get(safeItemOptionsBtn).eq(index).click() +} + +export function expandGroupSafes(index) { + cy.get(multichainItemSummary).eq(index).click() +} + +export function clickOnMultichainItemOptionsBtn(index) { + cy.get(multichainItemSummary).eq(index).find(safeItemOptionsBtn).click() +} + +export function checkMultichainTooltipExists(index) { + cy.get(multichainItemSummary).eq(index).find(chainLogo).eq(0).trigger('mouseover', { force: true }) + cy.get(multichainTooltip).should('exist') +} + +export function checkSafeGroupBalance(index) { + cy.get(multichainItemSummary) + .eq(index) + .find(groupBalance) + .invoke('text') + .should('include', '$') + .and('match', /\$?\s?\d+(\.\d{1,3})?/) +} + +export function checkSafeGroupAddress(index, address) { + cy.get(multichainItemSummary) + .eq(index) + .find(groupAddress) + .invoke('text') + .then((text) => { + expect(text).to.include(address) + }) +} +export function checkSafeGroupIconsExist(index, icons) { + cy.get(multichainItemSummary).eq(index).find(groupSafeIcon).should('have.length', 1) + cy.get(multichainItemSummary).eq(index).find(safeIcon).should('have.length', icons) +} + +export function getSubAccountContainer(index) { + return cy.get(subAccountContainer).eq(index) +} + +export function checkThereIsNoOptionsMenu(index) { + getSubAccountContainer(index).find(safeItemOptionsBtn).should('not.exist') +} + +export function checkUndeployedSafeExists(index) { + return getSubAccountContainer(index).contains(notActivatedStr).should('exist') +} + +export function checkMultichainSubSafeExists(safes) { + main.verifyValuesExist(subAccountContainer, safes) +} + +export function checkAddNetworkBtnPosition(index) { + cy.get(multichainItemSummary) + .eq(index) + .should('exist') + .within(() => { + cy.get(addNetworkBtn) + .should('exist') + .should('be.visible') + .then(($btn) => { + expect($btn.parent().children().last()[0]).to.equal($btn[0]) + }) + }) +} +export function clickOnAddNetworkBtn() { + cy.get(addNetworkBtn).eq(0).click() + cy.get(addChainDialog).should('be.visible') +} + +export function getModalAddNetworkBtn() { + return cy.get(modalAddNetworkBtn) +} + +export function clickOnNetworkInput() { + cy.get(networkInput).click() +} + +export function getNetworkOptions() { + return cy.get(networkOption) +} + +export function addNetwork(network) { + clickOnAddNetworkBtn() + clickOnNetworkInput() + getNetworkOptions().contains(network).click() + getModalAddNetworkBtn().click() +} + +export function renameSafeItem(oldName, newName) { + clickOnSafeItemOptionsBtn(oldName) + clickOnRenameBtn() + typeSafeName(newName) +} + +export function removeSafeItem(name) { + clickOnSafeItemOptionsBtn(name) + cy.wait(1000) + clickOnRemoveBtn() + confirmSafeItemRemoval() + verifyModalRemoved() +} + +function typeSafeName(name) { + cy.get(nameInput).find('input').clear().type(name) +} + +export function clickOnRenameBtn() { + cy.get(safeItemOptionsRenameBtn).click() + cy.get(address_book.entryDialog).should('exist') +} + +function clickOnRemoveBtn() { + cy.get(safeItemOptionsRemoveBtn).click() +} + +function confirmSafeItemRemoval() { + cy.get(deleteBtn).click() +} + +export function verifySafeNameExists(name) { + cy.get(sidebarSafeContainer).within(() => { + cy.get(sideSafeListItem).contains(name) + }) +} + +export function verifySafeRemoved(name) { + main.verifyValuesDoNotExist(sidebarSafeContainer, [name]) +} + +export function clickOnSaveBtn() { + cy.get(saveBtn).click() + verifyModalRemoved() +} + +function verifyModalRemoved() { + main.verifyElementsCount(modal.modalTitle, 0) +} + +export function checkCurrencyInHeader(currency) { + cy.get(sidebarSafeHeader).within(() => { + cy.get(currencySection).contains(currency) + }) +} + +export function checkSafeAddressInHeader(address) { + main.verifyValuesExist(sidebarSafeHeader, address) +} + +export function verifyPinnedListIsEmpty() { + cy.get(emptyPinnedList).should('contain.text', emptyPinnedMessage).find('svg').should('exist') +} + +export function verifySafeListIsEmpty() { + main.verifyValuesExist(sidebarSafeContainer, [emptySafeListStr]) +} + +export function verifySafeBookmarkBtnExists(safe) { + getSafeByName(safe).within(() => { + cy.get(boomarkIcon).should('exist') + }) +} + +export function clickOnBookmarkBtn(safe) { + getSafeByName(safe).within(() => { + cy.get(boomarkIcon).click() + cy.wait(500) + }) +} + +export function verifySafeGiveNameOptionExists(index) { + cy.get(safeItemMenuIcon).eq(index).click() + clickOnRenameBtn() +} + +export function checkAccountsCounter(value) { + verifySafeCount(2) + cy.get(sidebarSafeContainer) + .should('exist') + .then(($el) => { + const text = $el.text() + const match = text.match(accountsRegex) + expect(match).not.to.be.null + expect(match[0]).to.exist + }) +} + +export function checkTxToConfirm(numberOfTx) { + const str = confirmTxStr(numberOfTx) + main.verifyValuesExist(sideSafeListItem, [str]) +} + +export function verifyTxToConfirmDoesNotExist() { + main.verifyValuesDoNotExist(sideSafeListItem, [confirmGenStr]) +} + +export function checkBalanceExists() { + const balance = new RegExp(`\\s*\\d*\\.?\\d*\\s*`, 'i') + const element = cy.get(chainLogo).prev().contains(balance) +} + +export function checkAddChainDialogDisplayed() { + cy.get(safeItemOptionsAddChainBtn).click() + cy.get(addChainDialog).should('be.visible') +} + +export function clickOnShowAllNetworksBtn() { + cy.get(showAllNetworks).click() +} + +// TODO: Remove after next release due to data-testid availability +export function clickOnShowAllNetworksStrBtn() { + cy.contains(showAllNetworksStr).click() +} + +export function checkNetworkPresence(networks, optionSelector) { + return cy.get(optionSelector).then((options) => { + const optionTexts = [...options].map((option) => option.innerText) + networks.forEach((network) => { + const isNetworkPresent = optionTexts.some((text) => text.includes(network)) + expect(isNetworkPresent).to.be.true + }) + cy.wrap([...options].filter((option) => networks.some((network) => option.innerText.includes(network)))) + }) +} + +export function checkNetworkIsNotEditable() { + cy.get(addChainDialog).within(() => { + cy.get(modalAddNetworkName).should('exist') + }) + cy.get(addChainDialog).find(networkInput).should('not.exist') +} + +export function checkNetworksInRange(expectedString, expectedCount, direction = 'below') { + const networkSeparator = networkSeperator + const startSelector = networkSeparator + const endSelector = direction === 'below' ? showAllNetworks : 'ul' + + const traversalMethod = direction === 'below' ? 'nextUntil' : 'prevUntil' + + return cy + .get(startSelector) + [traversalMethod](endSelector, 'li') + .then((liElements) => { + expect(liElements.length).to.equal(expectedCount) + const optionTexts = [...liElements].map((li) => li.innerText) + const isStringPresent = optionTexts.some((text) => text.includes(expectedString)) + expect(isStringPresent).to.be.true + return cy.wrap(liElements) + }) +} + +export function checkInconsistentSignersMsgDisplayed(network) { + cy.contains(signersNotConsistentMsg).should('exist') + cy.contains(signersNotConsistentMsg2(network)).should('exist') + cy.contains(signersNotConsistentMsg3).should('exist') +} + +export function checkInconsistentSignersMsgDisplayedConfirmTxView(network) { + cy.contains(signersNotConsistentConfirmTxViewMsg(network)).should('exist') +} + +function getNetworkElements() { + return cy.get('span[data-track="overview: Add new network"] > li') +} + +export function checkNetworkDisabled(networks) { + getNetworkElements().should('have.length', 20) + getNetworkElements().each(($el) => { + const text = $el[0].innerText.trim() + console.log(`Element text: ${text}`) + const isDisabledNetwork = networks.some((network) => text.includes(network)) + if (isDisabledNetwork) { + expect($el).to.have.attr('aria-disabled', 'true') + } else { + expect($el).not.to.have.attr('aria-disabled') + } + }) +} diff --git a/cypress/e2e/pages/spending_limits.pages.js b/apps/web/cypress/e2e/pages/spending_limits.pages.js similarity index 90% rename from cypress/e2e/pages/spending_limits.pages.js rename to apps/web/cypress/e2e/pages/spending_limits.pages.js index d6178686b..a7b844282 100644 --- a/cypress/e2e/pages/spending_limits.pages.js +++ b/apps/web/cypress/e2e/pages/spending_limits.pages.js @@ -33,6 +33,9 @@ const oldTokenAmount = '[data-testid="old-token-amount"]' const oldResetTime = '[data-testid="old-reset-time"]' const slimitReplacementWarning = '[data-testid="limit-replacement-warning"]' const addressItem = '[data-testid="address-item"]' +const allActionsSection = '[data-testid="all-actions"]' +const actionItem = '[data-testid="action-item"]' +const decodedTxSummary = '[data-testid="decoded-tx-summary"]' const actionSectionItem = () => { return cy.get('[data-testid="CodeIcon"]').parent() @@ -48,7 +51,12 @@ export const timePeriodOptions = { const getBeneficiaryInput = () => cy.get(beneficiarySection).find('input').should('be.enabled') const automationOwner = ls.addressBookData.sepoliaAddress2[11155111]['0xC16Db0251654C0a72E91B190d81eAD367d2C6fED'] -const expectedSpendOptions = ['0 of 0.17 ETH', '0.00001 of 0.05 ETH', '0 of 0.01 ETH'] +export const actionNames = { + resetAllowance: 'resetAllowance', + setAllowance: 'setAllowance', +} + +const expectedSpendOptions = ['0.02 of 0.17 ETH', '0.00001 of 0.05 ETH', '0 of 0.01 ETH'] const expectedResetOptions = new Array(3).fill('One-time') const newTransactionStr = 'New transaction' @@ -205,3 +213,25 @@ export function verifyCharErrorValidation() { export function verifyNumberAmountEntered(amount) { cy.get(tokenAmountFld).find('input').should('have.value', amount) } + +export function verifyActionCount(count) { + main.verifyElementsCount(actionItem, count) +} + +export function verifyActionNames(names) { + cy.get(allActionsSection) + .parent() + .within(() => { + names.forEach((item) => { + cy.contains(item) + }) + }) +} + +export function verifyDecodedTxSummary(names) { + cy.get(decodedTxSummary).within(() => { + names.forEach((item) => { + cy.contains(item) + }) + }) +} diff --git a/apps/web/cypress/e2e/pages/staking.page.js b/apps/web/cypress/e2e/pages/staking.page.js new file mode 100644 index 000000000..f0daba1e0 --- /dev/null +++ b/apps/web/cypress/e2e/pages/staking.page.js @@ -0,0 +1,69 @@ +import * as main from './main.page.js' +import * as create_tx from './create_tx.pages.js' + +const existStr = 'Exit' +const validatorStatusStr = 'Validator status' + +export const dataFields = { + deposit: 'Deposit', + netRewardRate: 'Net reward rate', + netAnnualRewards: 'Net annual rewards', + netMonthlyRewards: 'Net monthly rewards', + fee: 'Fee', + validators: 'Validators', + activationTime: 'Activation time', + rewards: 'Rewards', +} + +export const validatorStatusOptions = { + withdrwal: 'Withdrawn', +} + +export const stakingTxs = { + claim: + '&id=multisig_0xAD1Cf279D18f34a13c3Bf9b79F4D427D5CD9505B_0xa763d9d136df5efc17e9825f4cca58033cd86a078b3e56500ccd1b53a2362e3b', + withdrawal: + '&id=multisig_0xAD1Cf279D18f34a13c3Bf9b79F4D427D5CD9505B_0x84f2ec635b73eaaea60ba813b12deaa370f413651ba08861cc0e9f080bffbecc', + stake: + '&id=multisig_0xAD1Cf279D18f34a13c3Bf9b79F4D427D5CD9505B_0xd1699838071a472d26963f2b823a4c835e9acd449d0376ca1d468a666903130d', +} + +export function getPercentageRegex() { + return new RegExp('^\\d+(\\.\\d+)?\\s?%$') +} + +export function getRewardRegex() { + return new RegExp('^\\d+(\\.\\d+)? ETH \\(\\$\\s?\\d{1,3}(,\\d{3})*\\)$') +} + +export function getActivationTimeRegex() { + return new RegExp('^\\d+\\s+hour(s)?\\s+\\d+\\s+minute(s)?$') +} + +export function checkTxHeaderData(data) { + main.verifyValuesExist(create_tx.transactionItem, data) +} + +export function verifyValidatorCount(count) { + cy.get(create_tx.txRowTitle).contains(existStr).parent().next().contains(`Validator ${count}`).should('exist') +} + +export function verifyValidatorStatus(status) { + cy.get(create_tx.txRowTitle).contains(validatorStatusStr).parent().next().contains(status).should('exist') +} + +export function checkDataFields(field, value) { + cy.get(create_tx.txRowTitle) + .contains(field) + .parent() + .next() + .invoke('text') + .then((text) => { + const trimmedText = text.trim() + if (value instanceof RegExp) { + expect(trimmedText).to.match(value) + } else { + expect(trimmedText).to.include(value) + } + }) +} diff --git a/apps/web/cypress/e2e/pages/swaps.pages.js b/apps/web/cypress/e2e/pages/swaps.pages.js new file mode 100644 index 000000000..2a640147b --- /dev/null +++ b/apps/web/cypress/e2e/pages/swaps.pages.js @@ -0,0 +1,709 @@ +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 table from '../pages/tables.page.js' +import * as modals from '../pages/modals.page.js' +import * as swaps_data from '../../fixtures/swaps_data.json' + +export const inputCurrencyInput = '[id="input-currency-input"]' +export const outputCurrencyInput = '[id="output-currency-input"]' +const tokenList = '[id="tokens-list"]' +export const swapBtn = '[id="swap-button"]' +const exceedFeesChkbox = 'input[id="fees-exceed-checkbox"]' +const settingsBtn = 'button[id="open-settings-dialog-button"]' +const settingsBtnTwap = 'button[id^="menu-button--menu"]' +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 orderTypeMenuItem = 'div[class*="MenuItem"]' +const explorerBtn = '[data-testid="explorer-btn"]' +const limitPriceFld = '[data-testid="limit-price"]' +const expiryFld = '[data-testid="expiry"]' +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"]' +const outputCurrencyTitle = (title) => `span[title*='${title}']` +const reviewTwapBtn = '[id="do-trade-button"]' +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 limitStrBtn = 'Limit' +const swapStrBtn = 'Swap' +const twapStrBtn = 'TWAP' +const confirmSwapStr = 'Confirm Swap' +const swapAnywayStrBtn = 'Swap anyway' +const maxStrBtn = 'Max' +const numberOfPartsStr = /No\.? of parts/ +const sellAmountStr = 'Sell amount' +const buyAmountStr = 'Buy amount' +const filledStr = 'Filled' +const partDuration = 'Part duration' +const totalDurationStr = 'Total duration' +const oneHr = '1 Hour' +const halfHr = '30m' +const sellperPartStr = 'Sell per part' +const sellperPartStr2 = 'Sell amount' +const buyperPartStr = 'Buy per part' +const priceProtectionStr = 'Price protection' +const orderSplit = 'Order will be split in' +const orderDetailsStr = 'Order details' +const unlockTwapOrdersStrBtn = 'Unlock TWAP orders' + +const getInsufficientBalanceStr = (token) => `Insufficient ${token} balance` +const sellAmountIsSmallStr = 'Sell amount too small' + +const swapBtnStr = /Confirm Swap|Swap|Confirm (Approve COW and Swap)|Confirm/ +const orderSubmittedStr = 'Order Submitted' +const orderIdStr = 'Order ID' +const cowOrdersUrl = 'https://explorer.cow.fi/orders' + +export const blockedAddress = '0x8576acc5c05d6ce88f4e49bf65bdf0c62f91353c' +export const blockedAddressStr = 'Blocked address' + +const swapStr = 'Swap' +const limitStr = 'Limit' + +const swapsHistory = swaps_data.type.history + +export const swapTokens = { + cow: 'COW', + dai: 'DAI', + eth: 'ETH', +} + +export const limitOrderExpiryOptions = { + five_minutes: '5 Minutes', +} + +export const swapTokenNames = { + eth: 'Ether', + cow: 'CoW Protocol Token', + daiTest: 'DAI (test)', + gnoTest: 'GNO (test)', + uni: 'Uniswap', + usdcTest: 'USDC (test)', + usdt: 'Tether USD', + weth: 'Wrapped Ether', +} + +export const orderTypes = { + swap: 'Swap', + limit: 'Limit', +} + +const swapOrders = '**/api/v1/orders/*' +const surplus = '**/users/*/total_surplus' +const nativePrice = '**/native_price' +const quote = '**/quote/*' + +export const limitOrderSafe = 'sep:0x8f4A19C85b39032A37f7a6dCc65234f966F72551' + +export const swapTxs = { + sell1Action: + '&id=multisig_0x03042B890b99552b60A073F808100517fb148F60_0xd033466000a40227fba7a7deb1a668371c213fec90bac9f2583096be2e0fd959', + buy2actions: + '&id=multisig_0x03042B890b99552b60A073F808100517fb148F60_0x135ff0282653d4c2a62c76cd247764b1abd4c0daa9201a72964feac2acaa7b44', + sellCancelled: + '&id=multisig_0x2a73e61bd15b25B6958b4DA3bfc759ca4db249b9_0xbe159adaa7fb0f7e80ad4bab33a2bb341043818478c96916cfa3877303d22a3d', + sell3Actions: + '&id=multisig_0x140663Cb76e4c4e97621395fc118912fa674150B_0x9f3d2c9c9879fb7eee7005d57b2b5c9006d7c8b98241aa49a0b9e769411c58ef', + sellLimitOrder: + '&id=multisig_0x03042B890b99552b60A073F808100517fb148F60_0xf7093c3e87e3b703a0df4d9360cd38254ed69d0dc4f7ff5399a194bd92e9014c', + sellQLimitOrder: + '&id=multisig_0xD8b85a669413b25a8BE7D7698f88b7bFA20889d2_0x4a699a1a0fe8dcf0bb2f1ccd550bd403dad6b93ca9b1f146aeed90f0a6de6c0c', + sellSwapQLimitOrder: + '&id=multisig_0xD8b85a669413b25a8BE7D7698f88b7bFA20889d2_0xc2a59a93e1cbaeab5fde7a5d4cc63938e1b1e4597c7e203146a6e6e07b43a92f', + sellTwapQLimitOrder: + '&id=multisig_0xD8b85a669413b25a8BE7D7698f88b7bFA20889d2_0x0f9fb46e5d85bdb11f85bdf356078bb2caaf5508504b5ddb8aba2ce5e3aa58ae', + sellLimitOrderFilled: + '&id=multisig_0x8f4A19C85b39032A37f7a6dCc65234f966F72551_0xd3d13db9fc438d0674819f81be62fcd9c74a8ed7c101a8249b8895e55ee80d76', + safeAppSwapOrder: + '&id=multisig_0x03042B890b99552b60A073F808100517fb148F60_0x5f08e05edb210a8990791e9df2f287a5311a8137815ec85856a2477a36552f1e', +} + +export function unlockTwapOrders(iframeSelector) { + main.getIframeBody(iframeSelector).then(($iframeBody) => { + if ($iframeBody.find(unlockOrdersBtn).length > 0) { + cy.wrap($iframeBody).find(unlockOrdersBtn).click() + cy.wait(500) + } + }) +} + +export function clickOnAssetSwapBtn(index) { + cy.get(assetsSwapBtn).eq(index).as('btn') + cy.get('@btn').click() +} + +export function verifyOrderSubmittedConfirmation() { + cy.get('div').contains(orderSubmittedStr).should('exist') +} + +export function clickOnSettingsBtn() { + cy.get(settingsBtn).click() +} + +export function clickOnSettingsBtnTwaps() { + cy.get(settingsBtnTwap).eq(0).click() +} + +export function setExpiry(value) { + cy.get('div').contains('Swap deadline').parent().next().find('input').clear().type(value) +} + +export function setLimitExpiry(value) { + cy.get('div').contains('Expiry').parent().find('button').click() + cy.get(limitOrderExpiryItem(value)).dblclick() +} + +export function enterRecipient(address) { + cy.get(customRecipient).find('input').clear().type(address) +} + +export function setSlippage(value) { + cy.contains('button', 'Auto').next('button').find('input').clear().type(value) +} +export function waitForOrdersCallToComplete() { + cy.intercept('GET', swapOrders).as('Orders') + cy.wait('@Orders') +} + +export function waitForSurplusCallToComplete() { + cy.intercept('GET', surplus).as('Surplus') + cy.wait('@Surplus') +} + +export function waitFornativePriceCallToComplete() { + cy.intercept('GET', nativePrice).as('Price') + cy.wait('@Price') +} + +export function waitForQuoteCallToComplete() { + cy.intercept('GET', quote).as('Quote') + cy.wait('@Quote') +} + +export function clickOnConfirmSwapBtn() { + cy.get('button').contains(confirmSwapStr).click() +} + +export function clickOnExceeFeeChkbox() { + cy.wait(1000) + cy.get(exceedFeesChkbox) + .should(() => {}) + .then(($button) => { + if (!$button.length) { + return + } + cy.wrap($button).click() + }) +} + +export function clickOnSwapBtn() { + cy.get('button').contains(swapBtnStr).as('swapBtn') + + cy.get('@swapBtn').should('exist').click({ force: true }) +} + +export function verifyReviewOrderBtnIsVisible() { + return cy.get(reviewTwapBtn).should('be.visible') +} + +export function clickOnReviewOrderBtn() { + cy.get('button') + .contains(swapAnywayStrBtn) + .should(() => {}) + .then(($button) => { + if (!$button.length) { + return + } + cy.wrap($button).click() + }) + cy.get(reviewTwapBtn).click() +} + +export function placeTwapOrder() { + cy.contains(placeTwapOrderStrBtn).click() +} + +export function placeLimitOrder() { + cy.contains(placeLimitOrderStrBtn).click() +} + +export function checkSwapBtnIsVisible() { + cy.get('button').contains(swapBtnStr).should('be.visible') +} + +export const currencyDirectionOptions = { + input: 'input', + output: 'output', +} + +export function acceptLegalDisclaimer() { + cy.get('button').contains('Continue').click() +} + +export function checkTokenBalance(safe, tokenSymbol) { + cy.get(inputCurrencyInput) + .invoke('text') + .then((text) => { + main.getSafeBalance(safe, constants.networkKeys.sepolia).then((response) => { + const targetToken = response.body.items.find((token) => token.tokenInfo.symbol === tokenSymbol) + const tokenBalance = targetToken.balance.toString() + let formattedBalance + + if (tokenBalance.length > 4) { + formattedBalance = `${tokenBalance[0]},${tokenBalance.slice(1, 4)}` + } else { + formattedBalance = tokenBalance + } + + expect(text).to.include(`${formattedBalance} ${tokenSymbol}`) + }) + }) +} + +export function verifySelectedInputCurrancy(option) { + cy.get(inputCurrencyInput).within(() => { + cy.get('span').contains(option).should('be.visible') + }) +} + +function selectCurrency(inputSelector, option) { + cy.get(inputSelector).within(() => { + cy.get('button') + .eq(0) + .invoke('text') + .then(($value) => { + cy.log('*** Currency value ' + $value) + if (!$value.includes(option)) { + cy.log('*** Currency value is different from specified') + cy.get('button').eq(0).trigger('mouseover').trigger('click') + cy.wrap(true).as('isAction') + } else { + cy.wrap(false).as('isAction') + } + }) + }) + + cy.get('@isAction').then((isAction) => { + if (isAction) { + cy.log('*** Clicking on token option') + cy.get(tokenList).find('span').contains(option).click() + } + }) +} + +export function selectInputCurrency(option) { + selectCurrency(inputCurrencyInput, option) +} + +export function selectOutputCurrency(option) { + selectCurrency(outputCurrencyInput, option) +} + +export function setInputValue(value) { + cy.get(inputCurrencyInput).within(() => { + cy.get('input').clear().type(value) + }) +} + +export function setOutputValue(value) { + cy.get(outputCurrencyInput).within(() => { + cy.get('input').type(value) + }) +} + +export function enableCustomRecipient(option) { + if (!option) cy.get(recipientToggle).click() +} + +export function enableTwapCustomRecipient() { + cy.get(twapsAddressToggle).click() +} + +export function disableCustomRecipient(option) { + if (option) cy.get(recipientToggle).click() +} + +export function isInputGreaterZero(inputSelector) { + return cy + .get(inputSelector) + .find('input') + .invoke('val') + .then((val) => { + const n = parseFloat(val) + return n > 0 + }) +} + +export function selectOrderType(type) { + cy.get('a').contains(swapStr).click() + cy.get(orderTypeMenuItem).contains(type).click() +} + +export function createRegex(pattern, placeholder) { + const pattern_ = pattern.replace(placeholder, `\\s*\\d*\\.?\\d*\\s*${placeholder}`) + return new RegExp(pattern_, 'i') +} + +export function getTokenPrice(token) { + return new RegExp(`\\d+(\\.\\d+)?\\s*${token}`, 'i') +} + +export function getOrderID() { + return new RegExp(`[a-fA-F0-9]{8}`, 'i') +} + +export function getWidgetFee() { + return new RegExp(`\\s*\\d*\\.?\\d+\\s*%\\s*`, 'i') +} + +export function getTokenValue() { + return new RegExp(`\\$\\d+\\.\\d{2}`, 'i') +} + +export function checkTokenOrder(regexPattern, option) { + cy.get(create_tx.txRowTitle) + .filter(`:contains("${option}")`) + .parent('div') + .then(($div) => { + const text = $div.text() + const regex = new RegExp(regexPattern, 'i') + + cy.wrap($div).should(($div) => { + expect(text).to.match(regex) + }) + }) +} + +export function verifyOrderIDUrl() { + cy.get(create_tx.txRowTitle) + .contains(orderIdStr) + .parent() + .parent() + .within(() => { + cy.get(explorerBtn).should('have.attr', 'href').and('include', cowOrdersUrl) + }) +} + +export function verifyOrderDetails(limitPrice, expiry, slippage, interactWith, oderID, widgetFee) { + cy.contains(limitPrice) + cy.contains(expiry) + cy.contains(slippage) + cy.contains(oderID) + cy.contains(widgetFee) + cy.contains(interactWith) +} + +export function verifyRecipientAlertIsDisplayed() { + main.verifyElementsIsVisible([recipientAlert]) +} + +export function closeIntroTwapModal() { + cy.get('button') + .contains(unlockTwapOrdersStrBtn) + .should(() => {}) + .then(($button) => { + if (!$button.length) { + return + } + cy.wrap($button).click() + cy.contains(unlockTwapOrdersStrBtn).should('not.exist') + cy.wait(500) + }) +} + +export function switchToTwap() { + cy.get('a').contains(swapStrBtn).click() + cy.wait(1000) + cy.get('a').contains(twapStrBtn).click() + cy.wait(1000) + closeIntroTwapModal() +} + +export function switchToLimit() { + cy.get('a').contains(swapStrBtn).click() + cy.wait(1000) + cy.get('a').contains(limitStrBtn).click() + cy.wait(1000) + closeIntroTwapModal() +} + +export function checkTokenBalanceAndValue(tokenDirection, balance, value) { + let direction = inputCurrencyInput + if (tokenDirection === 'output') direction = outputCurrencyInput + cy.get(direction).within(() => { + cy.contains(balance).should('be.visible') + cy.contains(value).should('be.visible') + }) +} + +export function checkSellAmount(amount) { + cy.contains(sellAmountStr) + .parent() + .parent() + .within(() => { + cy.contains(amount).should('exist') + }) +} + +export function checkBuyAmount(amount) { + cy.contains(buyAmountStr) + .parent() + .parent() + .within(() => { + cy.contains(amount).should('exist') + }) +} + +export function checkPartDuration(time) { + cy.contains(partDuration) + .parent() + .parent() + .within(() => { + cy.contains(time).should('exist') + }) +} + +export function checkPercentageFilled(percentage, str) { + cy.contains(filledStr) + .parent() + .parent() + .within(() => { + cy.contains(percentage) + cy.contains(str).should('exist') + cy.contains('sold').should('exist') + }) +} + +export function clickOnTokenSelctor(direction) { + let selector = inputCurrencyInput + if (direction === 'output') selector = outputCurrencyInput + cy.get(selector).find('button').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') + }) + }) +} + +export function clickOnMaxBtn() { + cy.get('button').contains(maxStrBtn).click() +} + +export function checkInputValue(direction, value) { + let selector = inputCurrencyInput + if (direction === 'output') selector = outputCurrencyInput + cy.get(selector).find('input').invoke('val').should('eq', value) +} + +export function checkInsufficientBalanceMessageDisplayed(token) { + const text = getInsufficientBalanceStr(token) + cy.get('button').contains(text).should('be.disabled') +} + +export function checkSmallSellAmountMessageDisplayed() { + cy.get('button').contains(sellAmountIsSmallStr).should('be.disabled') +} + +export function checkNumberOfParts(parts) { + cy.contains(numberOfPartsStr) + .parent() + .parent() + .within(() => { + cy.get(table.dataRow) + .invoke('text') + .then((text) => { + const partsInt = parseInt(text, 10) + expect(partsInt).to.eq(parts) + }) + }) +} + +export function checkTwapSettlement(index, sentValue, receivedValue) { + cy.get(groupedItems) + .eq(index) + .within(() => { + cy.get(create_tx.transactionItem).eq(0).contains(sentValue).should('exist') + cy.get(create_tx.transactionItem).eq(1).contains(receivedValue).should('exist') + }) +} + +export function getTwapInitialData() { + let formData = {} + + return cy + .wrap(null) + .then(() => { + cy.get(inputCurrencyInput).within(() => { + cy.get('input', { timeout: 10000 }) + .should(($input) => { + const value = parseFloat($input.val()) + expect(value).to.be.greaterThan(0) + }) + .invoke('val') + .should('not.be.empty') + .then((value) => { + formData.inputToken = value + }) + }) + + cy.get(outputCurrencyInput).within(() => { + cy.get('input', { timeout: 10000 }) + .should(($input) => { + const value = parseFloat($input.val()) + expect(value).to.be.greaterThan(0) + }) + .invoke('val') + .should('not.be.empty') + .then((value) => { + formData.outputToken = value + }) + }) + + cy.get(inputCurrencyInput).within(() => { + cy.get('button') + .find('span') + .filter((index, button) => Cypress.$(button).text().trim().length > 0) + .invoke('text') + .should('not.be.empty') + .then((text) => { + formData.inputTokenName = text + }) + }) + + cy.get(outputCurrencyInput).within(() => { + cy.get('button') + .filter((index, button) => Cypress.$(button).text().trim().length > 0) + .invoke('text') + .should('not.be.empty') + .then((text) => { + formData.outputTokenName = text + }) + }) + + cy.get('span') + .contains(totalDurationStr) + .next() + .invoke('text') + .should('not.be.empty') + .then((value) => { + formData.totalDuration = value + .toLowerCase() + .replace(/\bhours?\b/, 'hour') + .trim() + }) + + cy.get('span') + .contains(partDuration) + .next() + .invoke('text') + .should('not.be.empty') + .then((value) => { + formData.partDuration = value + .toLowerCase() + .replace(/(\d+)m\b/, '$1 minutes') + .trim() + }) + + cy.get(outputCurrencyInput).within(() => { + cy.get('input', { timeout: 10000 }) + .should(($input) => { + const value = parseFloat($input.val()) + expect(value).to.be.greaterThan(0) + }) + .invoke('val') + .should('not.be.empty') + .then((value) => { + formData.outputToken = value + }) + }) + + cy.get('span') + .contains(sellperPartStr) + .next() + .invoke('text') + .should('not.be.empty') + .then((value) => { + formData.sellPart = value + }) + + cy.get('span') + .contains(numberOfPartsStr) + .next() + .find('input') + .invoke('val') + .should('not.be.empty') + .then((value) => { + formData.numberOfParts = value + }) + }) + .then(() => { + console.log('****************** Collected FormData:', formData) + return cy.wrap(formData) + }) +} + +export function checkTwapValuesInReviewScreen(formData) { + main.verifyValuesExist(modals.cardContent, [ + orderDetailsStr, + formData.inputToken, + formData.inputTokenName, + formData.outputTokenName, + formData.sellPart, + swapsHistory.interactWith, + swapsHistory.widget_fee, + swapsHistory.slippage, + swapsHistory.expiry, + swapsHistory.limitPrice, + ]) + + cy.get(create_tx.txRowTitle) + .contains(totalDurationStr) + .parent() + .next() + .invoke('text') + .then((displayedValue) => { + const normalizedDisplayedValue = displayedValue + .toLowerCase() + .replace(/\bhours?\b/, 'hour') + .trim() + expect(normalizedDisplayedValue).to.eq(formData.totalDuration) + }) + + cy.get(create_tx.txRowTitle) + .contains(partDuration) + .parent() + .next() + .invoke('text') + .then((displayedValue) => { + const normalizedDisplayedValue = displayedValue + .toLowerCase() + .replace(/\b(m|minutes?)\b/, 'minutes') + .trim() + expect(normalizedDisplayedValue).to.eq(formData.partDuration) + }) + + cy.get(create_tx.txRowTitle).contains(sellperPartStr2).parent().next().should('contain', formData.sellPart) + + cy.get('p') + .contains(orderSplit) + .invoke('text') + .then((text) => { + expect(text).to.include(formData.numberOfParts) + }) +} diff --git a/apps/web/cypress/e2e/pages/tables.page.js b/apps/web/cypress/e2e/pages/tables.page.js new file mode 100644 index 000000000..de71b5632 --- /dev/null +++ b/apps/web/cypress/e2e/pages/tables.page.js @@ -0,0 +1 @@ +export const dataRow = '[data-testid="tx-data-row"]' diff --git a/apps/web/cypress/e2e/pages/transactions.page.js b/apps/web/cypress/e2e/pages/transactions.page.js new file mode 100644 index 000000000..c5c2a60b7 --- /dev/null +++ b/apps/web/cypress/e2e/pages/transactions.page.js @@ -0,0 +1,74 @@ +const executeNowOption = '[data-testid="execute-checkbox"]' +const executeLaterOption = '[data-testid="sign-checkbox"]' +const connectedWalletExecutionMethod = '[data-testid="connected-wallet-execution-method"]' +const txStatus = '[data-testid="transaction-status"]' +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 executeBtnStr = 'Execute' +const txCompletedStr = 'Transaction was successful' +export const relayRemainingAttemptsStr = 'free transactions left today' + +export function verifyTxConfirmBtnDisabled() { + cy.get(txConfirmBtn).should('be.disabled') +} + +export function verifySignBtnEnabled() { + cy.get(signBtn).should('be.enabled') +} + +export function selectExecuteNow() { + cy.get(executeNowOption).click() +} + +export function selectExecuteLater() { + cy.get(executeLaterOption).click() +} + +export function selectConnectedWalletOption() { + cy.get(connectedWalletExecutionMethod).click() +} + +export function selectRelayOtion() { + cy.get(connectedWalletExecutionMethod).prev().click() +} + +export function clickOnExecuteBtn() { + cy.get(executeFormBtn).click() +} + +export function clickOnFinishBtn() { + cy.get(finishTransactionBtn).click() +} + +export function waitForTxToComplete() { + cy.get(txStatus, { timeout: 240000 }).should('contain', txCompletedStr) +} + +export function executeFlow_1() { + selectExecuteNow() + selectConnectedWalletOption() + clickOnExecuteBtn() + // Wait for tx to be processed + cy.wait(60000) + clickOnFinishBtn() +} + +export function executeFlow_2() { + selectExecuteNow() + selectRelayOtion() + clickOnExecuteBtn() + // Wait for tx to be processed + cy.wait(60000) + clickOnFinishBtn() +} + +export function executeFlow_3() { + selectConnectedWalletOption() + clickOnExecuteBtn() + // Wait for tx to be processed + cy.wait(60000) + clickOnFinishBtn() +} diff --git a/apps/web/cypress/e2e/prodhealthcheck/add_owner.cy.js b/apps/web/cypress/e2e/prodhealthcheck/add_owner.cy.js new file mode 100644 index 000000000..e7ea7cee7 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/add_owner.cy.js @@ -0,0 +1,30 @@ +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' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('[PROD] Add Owners tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + acceptCookies2() + }) + + it('Verify add owner button is disabled for disconnected user', () => { + owner.verifyAddOwnerBtnIsDisabled() + }) + + it('Verify the Add New Owner Form can be opened', () => { + wallet.connectSigner(signer) + owner.openAddOwnerWindow() + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/create_tx.cy.js b/apps/web/cypress/e2e/prodhealthcheck/create_tx.cy.js new file mode 100644 index 000000000..ca3e57f2f --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/create_tx.cy.js @@ -0,0 +1,46 @@ +import * as constants from '../../support/constants' +import * as createtx from '../../e2e/pages/create_tx.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import { acceptCookies2 } from '../pages/main.page.js' + +let staticSafes = [] + +const sendValue = 0.00002 + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +function happyPathToStepTwo() { + createtx.typeRecipientAddress(constants.EOA) + createtx.clickOnTokenselectorAndSelectSepoliaEth() + createtx.setSendValue(sendValue) + createtx.clickOnNextBtn() +} + +describe('[PROD] Create transactions tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + acceptCookies2() + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + }) + + it('Verify submitting a tx and that clicking on notification shows the transaction in queue', () => { + happyPathToStepTwo() + createtx.verifySubmitBtnIsEnabled() + createtx.changeNonce(14) + cy.wait(1000) + createtx.clickOnSignTransactionBtn() + createtx.waitForProposeRequest() + createtx.clickViewTransaction() + createtx.verifySingleTxPage() + createtx.verifyQueueLabel() + createtx.verifyTransactionSummary(sendValue) + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/load_safe.cy.js b/apps/web/cypress/e2e/prodhealthcheck/load_safe.cy.js new file mode 100644 index 000000000..53be43bad --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/load_safe.cy.js @@ -0,0 +1,31 @@ +import 'cypress-file-upload' +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' + +let staticSafes = [] + +const testSafeName = 'Test safe name' +const testOwnerName = 'Test Owner Name' + +describe('[PROD] Load Safe tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.loadNewSafeSepoliaUrl) + acceptCookies2() + }) + + it('Verify Safe and owner names are displayed in the Review step', () => { + safe.inputNameAndAddress(testSafeName, staticSafes.SEP_STATIC_SAFE_4) + safe.clickOnNextBtn() + createwallet.typeOwnerName(testOwnerName, 0) + safe.clickOnNextBtn() + safe.verifyDataInReviewSection(testSafeName, testOwnerName) + safe.clickOnAddBtn() + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/messages_onchain.cy.js b/apps/web/cypress/e2e/prodhealthcheck/messages_onchain.cy.js new file mode 100644 index 000000000..f0daa16ab --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/messages_onchain.cy.js @@ -0,0 +1,29 @@ +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' + +let staticSafes = [] + +const typeMessagesOnchain = msg_data.type.onChain + +describe('[PROD] Onchain Messages tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_10) + acceptCookies2() + }) + + it('Verify summary for signed on-chain message', () => { + createTx.verifySummaryByName( + typeMessagesOnchain.contractName, + null, + [typeMessagesOnchain.success, typeMessagesOnchain.signMessage], + typeMessagesOnchain.altImage, + ) + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/multichain_network.cy.js b/apps/web/cypress/e2e/prodhealthcheck/multichain_network.cy.js new file mode 100644 index 000000000..f2633d120 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/multichain_network.cy.js @@ -0,0 +1,78 @@ +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 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' + +let staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('[PROD] Multichain add network tests', () => { + before(async () => { + 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) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.clickOnSafeItemOptionsBtnByIndex(0) + sideBar.clickOnAddNetworkBtn() + sideBar.clickOnNetworkInput() + sideBar.getNetworkOptions().contains(constants.networks.zkSync).parent().should('include', 'Not available') + }) + + // 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) + cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.ZKSYNC_STATIC_SAFE_29) + 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') + create_wallet.clickOnContinueWithWalletBtn() + create_wallet.clickOnCreateNewSafeBtn() + create_wallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) + cy.contains('li', constants.networks.zkSync).should('have.attr', 'aria-disabled', 'true') + }) + + 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') + create_wallet.clickOnContinueWithWalletBtn() + create_wallet.clickOnCreateNewSafeBtn() + create_wallet.clearNetworkInput(1) + create_wallet.enterNetwork(1, 'zkSync') + 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', () => { + const safe = 'eth:0x55d93DF21332615D48EA0c0144c7b1D176F3e7cb' + cy.visit(constants.prodbaseUrl + constants.setupUrl + safe) + 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', () => { + cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.MATIC_STATIC_SAFE_28) + create_wallet.openNetworkSelector() + sideBar.clickOnShowAllNetworksStrBtn() + sideBar.checkNetworkDisabled([constants.networks.zkSync, constants.networks.gnosisChiado]) + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/nfts.cy.js b/apps/web/cypress/e2e/prodhealthcheck/nfts.cy.js new file mode 100644 index 000000000..a3eae33f6 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/nfts.cy.js @@ -0,0 +1,90 @@ +import * as constants from '../../support/constants' +import * as nfts from '../pages/nfts.pages' +import * as createTx 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 { suspendOutreachModal } from '../pages/modals.page.js' + +const multipleNFT = ['multiSend'] +const multipleNFTAction = 'safeTransferFrom' +const NFTSentName = 'GTT #22' + +let nftsSafes, + staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('[PROD] NFTs tests', () => { + before(() => { + getSafes(CATEGORIES.nfts) + .then((nfts) => { + nftsSafes = nfts + return getSafes(CATEGORIES.static) + }) + .then((statics) => { + staticSafes = statics + }) + }) + + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_2) + wallet.connectSigner(signer) + acceptCookies2() + nfts.waitForNftItems(2) + suspendOutreachModal() + }) + + it('Verify multipls NFTs can be selected and reviewed', () => { + nfts.verifyInitialNFTData() + nfts.selectNFTs(3) + nfts.deselectNFTs([2], 3) + nfts.sendNFT() + nfts.verifyNFTModalData() + nfts.typeRecipientAddress(getMockAddress()) + nfts.clikOnNextBtn() + nfts.verifyReviewModalData(2) + }) + + it('Verify that when 2 NFTs are selected, actions and tx details are correct in Review step', () => { + nfts.verifyInitialNFTData() + nfts.selectNFTs(2) + nfts.sendNFT() + nfts.typeRecipientAddress(getMockAddress()) + nfts.clikOnNextBtn() + nfts.verifyTxDetails(multipleNFT) + nfts.verifyCountOfActions(2) + nfts.verifyActionName(0, multipleNFTAction) + nfts.verifyActionName(1, multipleNFTAction) + }) + + it('Verify Send button is disabled for non-owner', () => { + cy.visit(constants.prodbaseUrl + constants.balanceNftsUrl + nftsSafes.SEP_NFT_SAFE_2) + nfts.verifyInitialNFTData() + acceptCookies2() + suspendOutreachModal() + nfts.selectNFTs(1) + nfts.verifySendNFTBtnDisabled() + }) + + it('Verify Send NFT transaction has been created', () => { + cy.visit(constants.prodbaseUrl + constants.balanceNftsUrl + nftsSafes.SEP_NFT_SAFE_1) + wallet.connectSigner(signer) + nfts.verifyInitialNFTData() + acceptCookies2() + suspendOutreachModal() + nfts.selectNFTs(1) + nfts.sendNFT() + nfts.typeRecipientAddress(staticSafes.SEP_STATIC_SAFE_1) + createTx.changeNonce(2) + nfts.clikOnNextBtn() + createTx.clickOnSignTransactionBtn() + createTx.waitForProposeRequest() + createTx.clickViewTransaction() + createTx.verifySingleTxPage() + createTx.verifyQueueLabel() + createTx.verifyTransactionStrExists(NFTSentName) + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/recovery.cy.js b/apps/web/cypress/e2e/prodhealthcheck/recovery.cy.js new file mode 100644 index 000000000..e201a66a3 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/recovery.cy.js @@ -0,0 +1,49 @@ +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' + +let recoverySafes, + staticSafes = [] + +describe('[PROD] Production recovery health check tests', { defaultCommandTimeout: 50000 }, () => { + before(() => { + getSafes(CATEGORIES.recovery) + .then((recoveries) => { + recoverySafes = recoveries + return getSafes(CATEGORIES.static) + }) + .then((statics) => { + staticSafes = statics + }) + }) + + it('Verify that the Security section contains Account recovery block on supported netwroks', () => { + const safes = [ + staticSafes.ETH_STATIC_SAFE_15, + staticSafes.GNO_STATIC_SAFE_16, + staticSafes.MATIC_STATIC_SAFE_17, + staticSafes.SEP_STATIC_SAFE_13, + ] + + safes.forEach((safe) => { + cy.visit(constants.prodbaseUrl + constants.securityUrl + safe) + recovery.getSetupRecoveryBtn() + }) + }) + + it('Verify that the Security and Login section does not contain Account recovery block on unsupported networks', () => { + const safes = [ + staticSafes.BNB_STATIC_SAFE_18, + staticSafes.AURORA_STATIC_SAFE_19, + staticSafes.AVAX_STATIC_SAFE_20, + staticSafes.LINEA_STATIC_SAFE_21, + staticSafes.ZKSYNC_STATIC_SAFE_22, + ] + + safes.forEach((safe) => { + cy.visit(constants.prodbaseUrl + constants.securityUrl + safe) + 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 new file mode 100644 index 000000000..150672993 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/remove_owner.cy.js @@ -0,0 +1,42 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' +import * as createwallet from '../pages/create_wallet.pages' +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 { acceptCookies2 } from '../pages/main.page.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('[PROD] Remove Owners tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.SEP_STATIC_SAFE_13) + main.waitForHistoryCallToComplete() + acceptCookies2() + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + }) + + it('Verify owner deletion transaction has been created', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + owner.openRemoveOwnerWindow(1) + cy.wait(3000) + createwallet.clickOnNextBtn() + //This method creates the @removedAddress alias + owner.getAddressToBeRemoved() + owner.verifyOwnerDeletionWindowDisplayed() + createTx.changeNonce(10) + createTx.clickOnSignTransactionBtn() + createTx.waitForProposeRequest() + createTx.clickViewTransaction() + createTx.clickOnTransactionItemByName('removeOwner') + createTx.verifyTxDestinationAddress('@removedAddress') + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/sidebar.cy.js b/apps/web/cypress/e2e/prodhealthcheck/sidebar.cy.js new file mode 100644 index 000000000..6d481ae47 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/sidebar.cy.js @@ -0,0 +1,41 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +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' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('[PROD] Sidebar tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.homeUrl + staticSafes.SEP_STATIC_SAFE_9) + acceptCookies2() + }) + + it('Verify current safe details', () => { + sideBar.verifySafeHeaderDetails(sideBar.testSafeHeaderDetails) + }) + + it('Verify New transaction button enabled for owners', () => { + wallet.connectSigner(signer) + sideBar.verifyNewTxBtnStatus(constants.enabledStates.enabled) + }) + + it('Verify New transaction button enabled for beneficiaries who are non-owners', () => { + cy.visit(constants.prodbaseUrl + constants.homeUrl + staticSafes.SEP_STATIC_SAFE_11) + wallet.connectSigner(signer) + sideBar.verifyNewTxBtnStatus(constants.enabledStates.enabled) + }) + + it('Verify New Transaction button disabled for non-owners', () => { + main.verifyElementsCount(navigation.newTxBtn, 0) + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/sidebar_3.cy.js b/apps/web/cypress/e2e/prodhealthcheck/sidebar_3.cy.js new file mode 100644 index 000000000..f5f1d6e67 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/sidebar_3.cy.js @@ -0,0 +1,63 @@ +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 navigation from '../pages/navigation.page.js' +import * as owner from '../pages/owners.pages.js' +import { acceptCookies2 } from '../pages/main.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 + +describe('[PROD] Sidebar tests 3', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + 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.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + acceptCookies2() + sideBar.openSidebar() + sideBar.checkAccountsCounter('2') + }) + + it('Verify pending signature is displayed in sidebar for unsigned tx', () => { + cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + 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) + }) + + it('Verify balance exists in a tx in sidebar', () => { + cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + wallet.connectSigner(signer) + acceptCookies2() + owner.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafesPendingActions.safe1], + }) + sideBar.openSidebar() + sideBar.verifyTxToConfirmDoesNotExist() + sideBar.checkBalanceExists() + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/spending_limits.cy.js b/apps/web/cypress/e2e/prodhealthcheck/spending_limits.cy.js new file mode 100644 index 000000000..127838bf5 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/spending_limits.cy.js @@ -0,0 +1,50 @@ +import * as constants from '../../support/constants' +import * as spendinglimit from '../pages/spending_limits.pages' +import * as navigation from '../pages/navigation.page' +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' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const tokenAmount = 0.1 + +describe('[PROD] Spending limits tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.SEP_STATIC_SAFE_8) + cy.get(spendinglimit.spendingLimitsSection).should('be.visible') + acceptCookies2() + }) + + it('Verify that the Review step shows beneficiary, amount allowed, reset time', () => { + //Assume that default reset time is set to One time + wallet.connectSigner(signer) + spendinglimit.clickOnNewSpendingLimitBtn() + spendinglimit.enterBeneficiaryAddress(getMockAddress()) + spendinglimit.enterSpendingLimitAmount(0.1) + spendinglimit.clickOnNextBtn() + spendinglimit.checkReviewData( + tokenAmount, + getMockAddress(), + spendinglimit.timePeriodOptions.oneTime.split(' ').join('-'), + ) + }) + + it('Verify values and trash icons are displayed in Beneficiary table', () => { + spendinglimit.verifyBeneficiaryTable() + }) + + it('Verify Spending limit option is available when selecting the corresponding token', () => { + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + tx.clickOnSendTokensBtn() + spendinglimit.verifyTxOptionExist([spendinglimit.spendingLimitTxOption]) + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/swaps_history_2.cy.js b/apps/web/cypress/e2e/prodhealthcheck/swaps_history_2.cy.js new file mode 100644 index 000000000..5c28a1cd5 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/swaps_history_2.cy.js @@ -0,0 +1,59 @@ +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' +import { acceptCookies2 } from '../pages/main.page.js' + +let staticSafes = [] + +const swapsHistory = swaps_data.type.history +const typeGeneral = data.type.general + +describe('[PROD] Swaps history tests 2', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify swap buy operation with 2 actions: approve & swap', { defaultCommandTimeout: 30000 }, () => { + cy.visit( + constants.prodbaseUrl + constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_1 + swaps.swapTxs.buy2actions, + ) + acceptCookies2() + const eq = swaps.createRegex(swapsHistory.oneGNOFull, 'COW') + const atMost = swaps.createRegex(swapsHistory.forAtMostCow, 'COW') + + create_tx.verifyExpandedDetails([ + swapsHistory.buyOrder, + swapsHistory.buy, + eq, + atMost, + swapsHistory.cow, + swapsHistory.expired, + swapsHistory.actionApprove, + swapsHistory.actionPreSignature, + ]) + }) + + it( + 'Verify there is decoding for a tx created by CowSwap safe-app in the history', + { defaultCommandTimeout: 30000 }, + () => { + cy.visit( + constants.prodbaseUrl + + constants.transactionUrl + + staticSafes.SEP_STATIC_SAFE_1 + + swaps.swapTxs.safeAppSwapOrder, + ) + const dai = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqCOW, 'COW') + acceptCookies2() + main.verifyValuesExist(create_tx.transactionItem, [swapsHistory.title]) + create_tx.verifySummaryByName(swapsHistory.title, null, [typeGeneral.statusOk]) + main.verifyElementsExist([create_tx.altImgDai, create_tx.altImgCow], create_tx.altImgSwaps) + create_tx.verifyExpandedDetails([swapsHistory.sell10Cow, dai, eq, swapsHistory.dai, swapsHistory.filled]) + }, + ) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/swaps_tokens.cy.js b/apps/web/cypress/e2e/prodhealthcheck/swaps_tokens.cy.js new file mode 100644 index 000000000..a66d0c534 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/swaps_tokens.cy.js @@ -0,0 +1,40 @@ +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 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' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +let iframeSelector = `iframe[src*="${constants.swapWidget}"]` + +describe('[PROD] Swaps token tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_1) + acceptCookies2() + }) + + it( + 'Verify that clicking the swap from assets tab, autofills that token automatically in the form', + { defaultCommandTimeout: 30000 }, + () => { + wallet.connectSigner(signer) + assets.selectTokenList(assets.tokenListOptions.allTokens) + + swaps.clickOnAssetSwapBtn(0) + swaps.acceptLegalDisclaimer() + cy.wait(2000) + main.getIframeBody(iframeSelector).within(() => { + swaps.verifySelectedInputCurrancy(swaps.swapTokens.eth) + }) + }, + ) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/tokens.cy.js b/apps/web/cypress/e2e/prodhealthcheck/tokens.cy.js new file mode 100644 index 000000000..6441d4543 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/tokens.cy.js @@ -0,0 +1,91 @@ +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' + +const TOKEN_AMOUNT_COLUMN = 1 +const FIAT_AMOUNT_COLUMN = 2 + +let staticSafes = [] + +describe('[PROD] Prod tokens tests', () => { + const fiatRegex = assets.fiatRegex + + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + beforeEach(() => { + cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_2) + acceptCookies2() + }) + + it('Verify that non-native tokens are present and have balance', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.verifyBalance(assets.currencyDaiCap, TOKEN_AMOUNT_COLUMN, assets.currencyDaiAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyDaiCap, + assets.currencyDaiFormat_2, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyAave, TOKEN_AMOUNT_COLUMN, assets.currencyAaveAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyAave, + assets.currentcyAaveFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyLink, TOKEN_AMOUNT_COLUMN, assets.currencyLinkAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyLink, + assets.currentcyLinkFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyTestTokenA, TOKEN_AMOUNT_COLUMN, assets.currencyTestTokenAAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyTestTokenA, + assets.currentcyTestTokenAFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyTestTokenB, TOKEN_AMOUNT_COLUMN, assets.currencyTestTokenBAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyTestTokenB, + assets.currentcyTestTokenBFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyUSDC, TOKEN_AMOUNT_COLUMN, assets.currencyTestUSDCAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyUSDC, + assets.currentcyTestUSDCFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + }) + + it('Verify that when owner is disconnected, Send button is disabled', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.showSendBtn(0) + assets.VerifySendButtonIsDisabled() + }) + + it('Verify that when connected user is not owner, Send button is disabled', () => { + cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_3) + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.showSendBtn(0) + assets.VerifySendButtonIsDisabled() + }) +}) diff --git a/apps/web/cypress/e2e/prodhealthcheck/tx_history.cy.js b/apps/web/cypress/e2e/prodhealthcheck/tx_history.cy.js new file mode 100644 index 000000000..baf145c06 --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/tx_history.cy.js @@ -0,0 +1,127 @@ +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' +import { acceptCookies2 } from '../pages/main.page.js' + +let staticSafes = [] + +const typeCreateAccount = data.type.accountCreation +const typeReceive = data.type.receive +const typeSend = data.type.send +const typeSpendingLimits = data.type.spendingLimits +const typeDeleteAllowance = data.type.deleteSpendingLimit +const typeSideActions = data.type.sideActions +const typeGeneral = data.type.general + +describe('[PROD] Tx history tests 1', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.intercept( + 'GET', + `**${constants.stagingCGWChains}${constants.networkKeys.sepolia}/${ + constants.stagingCGWSafes + }${staticSafes.SEP_STATIC_SAFE_7.substring(4)}/transactions/history**`, + (req) => { + req.url = `https://safe-client.safe.global/v1/chains/11155111/safes/0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb/transactions/history?timezone=Europe/Berlin&trusted=false&cursor=limit=100&offset=1` + req.continue() + }, + ).as('allTransactions') + + cy.visit(constants.prodbaseUrl + constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + cy.wait('@allTransactions') + acceptCookies2() + }) + + // Account creation + it('Verify summary for account creation', () => { + createTx.verifySummaryByName( + typeCreateAccount.title, + null, + [typeCreateAccount.actionsSummary, typeGeneral.statusOk], + typeCreateAccount.altImage, + ) + }) + + it('Verify exapanded details for account creation', () => { + createTx.clickOnTransactionItemByName(typeCreateAccount.title) + createTx.verifyExpandedDetails([ + typeCreateAccount.creator.actionTitle, + typeCreateAccount.creator.address, + typeCreateAccount.factory.actionTitle, + typeCreateAccount.factory.name, + typeCreateAccount.factory.address, + typeCreateAccount.masterCopy.actionTitle, + typeCreateAccount.masterCopy.name, + typeCreateAccount.masterCopy.address, + typeCreateAccount.transactionHash, + ]) + }) + + // Token send + it('Verify exapanded details for token send', () => { + createTx.clickOnTransactionItemByName(typeSend.title, typeSend.summaryTxInfo) + createTx.verifyExpandedDetails([typeSend.sentTo, typeSend.recipientAddress, typeSend.transactionHash]) + createTx.verifyActionListExists([ + typeSideActions.created, + typeSideActions.confirmations, + typeSideActions.executedBy, + ]) + }) + + // Spending limits + it('Verify summary for setting spend limits', () => { + createTx.verifySummaryByName( + typeSpendingLimits.title, + typeSpendingLimits.summaryTxInfo, + [typeGeneral.statusOk], + typeSpendingLimits.altImage, + ) + }) + + 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, + ], + createTx.delegateCallWarning, + ) + }) + + it('Verify that 3 actions exist in initial spending limits setup', () => { + createTx.clickOnTransactionItemByName(typeSpendingLimits.title, typeSpendingLimits.summaryTxInfo) + createTx.verifyActions([ + typeSpendingLimits.enableModule.title, + typeSpendingLimits.addDelegate.title, + typeSpendingLimits.setAllowance.title, + ]) + }) + + // Spending limit deletion + it('Verify exapanded details for allowance deletion', () => { + createTx.clickOnTransactionItemByName(typeDeleteAllowance.title, typeDeleteAllowance.summaryTxInfo) + createTx.verifyExpandedDetails([ + typeDeleteAllowance.description, + typeDeleteAllowance.beneficiary, + typeDeleteAllowance.beneficiaryAddress, + typeDeleteAllowance.transactionHash, + typeDeleteAllowance.safeTxHash, + typeDeleteAllowance.token, + typeDeleteAllowance.tokenName, + ]) + }) + + it('Verify advanced details displayed in exapanded details for allowance deletion', () => { + createTx.clickOnTransactionItemByName(typeDeleteAllowance.title, typeDeleteAllowance.summaryTxInfo) + createTx.expandAdvancedDetails([typeDeleteAllowance.baseGas]) + 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 new file mode 100644 index 000000000..a7ad1394a --- /dev/null +++ b/apps/web/cypress/e2e/prodhealthcheck/tx_history_2.cy.js @@ -0,0 +1,126 @@ +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' + +let staticSafes = [] + +const typeOnchainRejection = data.type.onchainRejection +const typeBatch = data.type.batchNativeTransfer +const typeAddOwner = data.type.addOwner +const typeChangeOwner = data.type.swapOwner +const typeRemoveOwner = data.type.removeOwner +const typeDisableOwner = data.type.disableModule +const typeChangeThreshold = data.type.changeThreshold +const typeSideActions = data.type.sideActions +const typeGeneral = data.type.general +const typeUntrustedToken = data.type.untrustedReceivedToken + +describe('[PROD] Tx history tests 2', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.intercept( + 'GET', + `**${constants.stagingCGWChains}${constants.networkKeys.sepolia}/${ + constants.stagingCGWSafes + }${staticSafes.SEP_STATIC_SAFE_7.substring(4)}/transactions/history**`, + (req) => { + req.url = `https://safe-client.safe.global/v1/chains/11155111/safes/0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb/transactions/history?timezone=Europe/Berlin&trusted=false&cursor=limit=100&offset=1` + req.continue() + }, + ).as('allTransactions') + + cy.visit(constants.prodbaseUrl + constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + acceptCookies2() + }) + + it('Verify number of transactions is correct', () => { + createTx.verifyNumberOfTransactions(20) + }) + + // On-chain rejection + it('Verify exapanded details for on-chain rejection', () => { + createTx.clickOnTransactionItemByName(typeOnchainRejection.title) + createTx.verifyExpandedDetails([ + typeOnchainRejection.description, + typeOnchainRejection.transactionHash, + typeOnchainRejection.safeTxHash, + ]) + createTx.verifyActionListExists([ + typeSideActions.rejectionCreated, + typeSideActions.confirmations, + typeSideActions.executedBy, + ]) + }) + + // 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.verifyActions([typeBatch.nativeTransfer.title]) + }) + + // Add owner + it('Verify summary for adding owner', () => { + createTx.verifySummaryByName(typeAddOwner.title, null, [typeGeneral.statusOk], typeAddOwner.altImage) + }) + + // Change owner + it('Verify summary for changing owner', () => { + createTx.verifySummaryByName(typeChangeOwner.title, null, [typeGeneral.statusOk], typeChangeOwner.altImage) + }) + + it('Verify exapanded details for changing owner', () => { + createTx.clickOnTransactionItemByName(typeChangeOwner.title) + createTx.verifyExpandedDetails([ + typeChangeOwner.description, + typeChangeOwner.newOwner.actionTitile, + typeChangeOwner.newOwner.ownerAddress, + typeChangeOwner.oldOwner.actionTitile, + typeChangeOwner.oldOwner.ownerAddress, + + typeChangeOwner.transactionHash, + typeChangeOwner.safeTxHash, + ]) + }) + + // Remove owner + it('Verify summary for removing owner', () => { + createTx.verifySummaryByName(typeRemoveOwner.title, null, [typeGeneral.statusOk], typeRemoveOwner.altImage) + }) + + // Disbale module + it('Verify summary for disable module', () => { + createTx.verifySummaryByName(typeDisableOwner.title, null, [typeGeneral.statusOk], typeDisableOwner.altImage) + }) + + // Change threshold + it('Verify summary for changing threshold', () => { + createTx.verifySummaryByName(typeChangeThreshold.title, null, [typeGeneral.statusOk], typeChangeThreshold.altImage) + }) + + it('Verify exapanded details for changing threshold', () => { + createTx.clickOnTransactionItemByName(typeChangeThreshold.title) + createTx.verifyExpandedDetails( + [ + typeChangeThreshold.requiredConfirmationsTitle, + typeChangeThreshold.transactionHash, + typeChangeThreshold.safeTxHash, + ], + createTx.policyChangeWarning, + ) + createTx.checkRequiredThreshold(2) + }) + + it('Verify that sender address of untrusted token will not be copied until agreed in warning popup', () => { + createTx.clickOnTransactionItemByName(typeUntrustedToken.summaryTitle, typeUntrustedToken.summaryTxInfo) + createTx.verifyAddressNotCopied(0, typeUntrustedToken.senderAddress) + }) +}) diff --git a/apps/web/cypress/e2e/regression/add_owner.cy.js b/apps/web/cypress/e2e/regression/add_owner.cy.js new file mode 100644 index 000000000..2c5813c68 --- /dev/null +++ b/apps/web/cypress/e2e/regression/add_owner.cy.js @@ -0,0 +1,81 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' +import * as addressBook from '../pages/address_book.page' +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 walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Add Owners tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + }) + + // Added to prod + it('Verify add owner button is disabled for disconnected user', () => { + owner.verifyAddOwnerBtnIsDisabled() + }) + + // Added to prod + it('Verify the Add New Owner Form can be opened', () => { + wallet.connectSigner(signer) + owner.openAddOwnerWindow() + }) + + it('Verify error message displayed if character limit is exceeded in Name input', () => { + wallet.connectSigner(signer) + owner.openAddOwnerWindow() + owner.typeOwnerName(main.generateRandomString(51)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.exceedChars) + }) + + it('Verify that the "Name" field is auto-filled with the relevant name from Address Book', () => { + cy.visit(constants.addressBookUrl + staticSafes.SEP_STATIC_SAFE_4) + addressBook.clickOnCreateEntryBtn() + addressBook.typeInName(constants.addresBookContacts.user1.name) + addressBook.typeInAddress(constants.addresBookContacts.user1.address) + addressBook.clickOnSaveEntryBtn() + 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) + }) + + it('Verify that Name field not mandatory', () => { + wallet.connectSigner(signer) + owner.openAddOwnerWindow() + owner.typeOwnerAddress(getMockAddress()) + owner.clickOnNextBtn() + owner.verifyConfirmTransactionWindowDisplayed() + }) + + it('Verify default threshold value. Verify correct threshold calculation', () => { + wallet.connectSigner(signer) + owner.openAddOwnerWindow() + owner.typeOwnerAddress(constants.DEFAULT_OWNER_ADDRESS) + owner.verifyThreshold(1, 2) + }) + + it('Verify valid Address validation', () => { + wallet.connectSigner(signer) + owner.openAddOwnerWindow() + owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) + owner.clickOnNextBtn() + owner.verifyConfirmTransactionWindowDisplayed() + owner.clickOnBackBtn() + owner.typeOwnerAddress(staticSafes.SEP_STATIC_SAFE_3) + owner.clickOnNextBtn() + owner.verifyConfirmTransactionWindowDisplayed() + }) +}) diff --git a/apps/web/cypress/e2e/regression/address_book.cy.js b/apps/web/cypress/e2e/regression/address_book.cy.js new file mode 100644 index 000000000..b9a6391af --- /dev/null +++ b/apps/web/cypress/e2e/regression/address_book.cy.js @@ -0,0 +1,131 @@ +import 'cypress-file-upload' +const path = require('path') +import { format } from 'date-fns' +import * as constants from '../../support/constants' +import * as addressBook from '../../e2e/pages/address_book.page' +import * as main from '../../e2e/pages/main.page' +import * as ls from '../../support/localstorage_data.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' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +const NAME = 'Owner1' +const EDITED_NAME = 'Edited Owner1' +const importedSafe = 'imported-safe' + +describe('Address book tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.addressBookUrl + staticSafes.SEP_STATIC_SAFE_4) + }) + + it('Verify owners name can be edited', () => { + cy.wrap(null) + .then(() => + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1), + ) + .then(() => + main.isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1), + ) + .then(() => { + cy.reload() + addressBook.clickOnEditEntryBtn() + addressBook.typeInNameInput(EDITED_NAME) + addressBook.clickOnSaveEntryBtn() + addressBook.verifyNameWasChanged(NAME, EDITED_NAME) + }) + }) + + it('Verify the address book file can be exported', () => { + cy.wrap(null) + .then(() => main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.dataSet)) + .then(() => + main.isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.dataSet), + ) + .then(() => { + cy.reload() + cy.contains(ls.addressBookData.dataSet[11155111]['0xf405BC611F4a4c89CCB3E4d083099f9C36D966f8']) + const date = format(new Date(), 'yyyy-MM-dd', { timeZone: 'UTC' }) + const fileName = `safe-address-book-${date}.csv` + addressBook.clickOnExportFileBtn() + addressBook.verifyExportMessage(12) + addressBook.confirmExport() + const downloadsFolder = Cypress.config('downloadsFolder') + + cy.readFile(path.join(downloadsFolder, fileName), 'utf-8').then((content) => { + const lines = content + .replace(/^\uFEFF/, '') + .trim() + .split('\r\n') + + const [header, ...dataLines] = lines + const actualData = dataLines.reduce((acc, line) => { + const [address, name, chainId] = line.split(',') + acc[chainId] = acc[chainId] || {} + acc[chainId][address] = name + return acc + }, {}) + + Object.keys(ls.addressBookData.dataSet).forEach((chainId) => { + cy.log(`Checking chainId: ${chainId}`) + + const actualChainData = actualData[chainId] || {} + const expectedChainData = ls.addressBookData.dataSet[chainId] + + Object.keys(expectedChainData).forEach((address) => { + const actualName = actualChainData[address] + const expectedName = expectedChainData[address] + + cy.log( + `ChainId: ${chainId}, Address: ${address}, Actual Name: ${actualName}, Expected Name: ${expectedName}`, + ) + expect(actualName).to.equal(expectedName) + }) + }) + }) + }) + }) + + it('Verify that importing a csv file does not alter addresses in the Address book not present in the file', () => { + cy.wrap(null) + .then(() => + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1), + ) + .then(() => + main.isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1), + ) + .then(() => { + cy.wait(1000) + cy.reload() + addressBook.clickOnImportFileBtn() + addressBook.importCSVFile(addressBook.validCSVFile) + addressBook.clickOnImportBtn() + addressBook.verifyDataImported([constants.RECIPIENT_ADDRESS]) + }) + }) + + it('Verify Safe name changes after uploading a csv file', () => { + cy.wrap(null) + .then(() => main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set2)) + .then(() => + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.addedSafesImport), + ) + .then(() => { + cy.wait(1000) + cy.reload() + addressBook.clickOnImportFileBtn() + addressBook.importCSVFile(addressBook.addedSafesCSVFile) + addressBook.clickOnImportBtn() + wallet.connectSigner(signer) + sidebar.openSidebar() + sidebar.verifyAddedSafesExist([importedSafe]) + }) + }) +}) diff --git a/cypress/e2e/regression/address_book_2.cy.js b/apps/web/cypress/e2e/regression/address_book_2.cy.js similarity index 89% rename from cypress/e2e/regression/address_book_2.cy.js rename to apps/web/cypress/e2e/regression/address_book_2.cy.js index 9f49706fa..39859b85f 100644 --- a/cypress/e2e/regression/address_book_2.cy.js +++ b/apps/web/cypress/e2e/regression/address_book_2.cy.js @@ -21,9 +21,6 @@ describe('Address book tests - 2', () => { beforeEach(() => { cy.visit(constants.addressBookUrl + staticSafes.SEP_STATIC_SAFE_4) - cy.clearLocalStorage() - cy.wait(1000) - main.acceptCookies() }) it('Verify Name and Address columns sorting works', () => { @@ -67,14 +64,6 @@ describe('Address book tests - 2', () => { addressBook.verifyNameWasChanged(owner1, onwer3) }) - it.skip('Verify copy to clipboard/Etherscan work as expected', () => { - main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1) - cy.wait(1000) - cy.reload() - createtx.verifyCopyIconWorks(0, constants.RECIPIENT_ADDRESS) - createtx.verifyNumberOfExternalLinks(1) - }) - it('Verify by default there 25 rows shown per page', () => { main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.pagination) cy.wait(1000) diff --git a/apps/web/cypress/e2e/regression/address_book_3.cy.js b/apps/web/cypress/e2e/regression/address_book_3.cy.js new file mode 100644 index 000000000..a2c12bde6 --- /dev/null +++ b/apps/web/cypress/e2e/regression/address_book_3.cy.js @@ -0,0 +1,102 @@ +import 'cypress-file-upload' +import * as constants from '../../support/constants.js' +import * as addressBook from '../pages/address_book.page.js' +import * as main from '../pages/main.page.js' +import * as ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +let staticSafes = [] + +const NAME = 'Owner1' +const NAME_2 = 'Owner2' +const EDITED_NAME = 'Edited Owner1' +const duplicateEntry = 'test-sepolia-90' +const owner1 = 'Automation owner' +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const recipientData = [owner1, constants.DEFAULT_OWNER_ADDRESS] + +describe('Address book tests - 3', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.addressBookUrl + staticSafes.SEP_STATIC_SAFE_4) + }) + + it('Verify entry can be added', () => { + addressBook.clickOnCreateEntryBtn() + addressBook.addEntry(NAME, constants.RECIPIENT_ADDRESS) + }) + + it('Verify entry can be deleted', () => { + cy.wrap(null) + .then(() => + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1), + ) + .then(() => + main.isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1), + ) + .then(() => { + cy.reload() + addressBook.clickDeleteEntryButton() + addressBook.clickDeleteEntryModalDeleteButton() + addressBook.verifyEditedNameNotExists(EDITED_NAME) + }) + }) + + it('Verify csv file can be imported', () => { + addressBook.clickOnImportFileBtn() + addressBook.importCSVFile(addressBook.validCSVFile) + addressBook.verifyImportBtnStatus(constants.enabledStates.enabled) + addressBook.clickOnImportBtn() + addressBook.verifyDataImported(addressBook.entries) + addressBook.verifyNumberOfRows(4) + }) + + it('Import a csv file with an empty address/name/network in one row', () => { + addressBook.clickOnImportFileBtn() + addressBook.importCSVFile(addressBook.emptyCSVFile) + addressBook.verifyImportBtnStatus(constants.enabledStates.disabled) + addressBook.verifyUploadExportMessage([addressBook.uploadErrorMessages.emptyFile]) + }) + + it('Import a non-csv file', () => { + addressBook.clickOnImportFileBtn() + addressBook.importCSVFile(addressBook.nonCSVFile) + addressBook.verifyImportBtnStatus(constants.enabledStates.disabled) + addressBook.verifyUploadExportMessage([addressBook.uploadErrorMessages.fileType]) + }) + + it('Import a csv file with a repeated address and same network', () => { + addressBook.clickOnImportFileBtn() + addressBook.importCSVFile(addressBook.duplicatedCSVFile) + addressBook.verifyImportBtnStatus(constants.enabledStates.enabled) + addressBook.clickOnImportBtn() + addressBook.verifyDataImported([duplicateEntry]) + addressBook.verifyNumberOfRows(1) + }) + + it('Verify modal shows the amount of entries and networks detected', () => { + addressBook.clickOnImportFileBtn() + addressBook.importCSVFile(addressBook.networksCSVFile) + addressBook.verifyImportBtnStatus(constants.enabledStates.enabled) + addressBook.verifyModalSummaryMessage(4, 3) + }) + + it('Verify an entry can be added by ENS name', () => { + addressBook.clickOnCreateEntryBtn() + addressBook.addEntryByENS(NAME_2, constants.ENS_TEST_SEPOLIA) + }) + + it('Verify clicking on Send button autofills the recipient filed with correct value', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress2) + cy.wait(1000) + cy.reload() + wallet.connectSigner(signer) + addressBook.clickOnSendBtn() + addressBook.verifyRecipientData(recipientData) + }) +}) diff --git a/apps/web/cypress/e2e/regression/assets.cy.js b/apps/web/cypress/e2e/regression/assets.cy.js new file mode 100644 index 000000000..418fac951 --- /dev/null +++ b/apps/web/cypress/e2e/regression/assets.cy.js @@ -0,0 +1,48 @@ +import * as constants from '../../support/constants' +import * as assets from '../pages/assets.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as main from '../pages/main.page' + +let staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Assets tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_2) + }) + + it('Verify that "Hide token" button is present and opens the "Hide tokens menu"', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.openHideTokenMenu() + assets.verifyEachRowHasCheckbox() + }) + + it('Verify that clicking the button with an owner opens the Send funds form', () => { + wallet.connectSigner(signer) + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.clickOnSendBtn(0) + }) + + it('[SMOKE] Verify that Token list dropdown down options show/hide spam tokens', () => { + let spamTokens = [ + assets.currencyAave, + assets.currencyTestTokenA, + assets.currencyTestTokenB, + assets.currencyUSDC, + assets.currencyLink, + assets.currencyDaiCap, + ] + + main.verifyValuesDoNotExist(assets.tokenListTable, spamTokens) + assets.selectTokenList(assets.tokenListOptions.allTokens) + spamTokens.push(constants.tokenNames.sepoliaEther) + main.verifyValuesExist(assets.tokenListTable, spamTokens) + }) +}) diff --git a/cypress/e2e/regression/balances_pagination.cy.js b/apps/web/cypress/e2e/regression/balances_pagination.cy.js similarity index 90% rename from cypress/e2e/regression/balances_pagination.cy.js rename to apps/web/cypress/e2e/regression/balances_pagination.cy.js index b110310b9..fdc658657 100644 --- a/cypress/e2e/regression/balances_pagination.cy.js +++ b/apps/web/cypress/e2e/regression/balances_pagination.cy.js @@ -5,14 +5,9 @@ import * as main from '../../e2e/pages/main.page' const ASSETS_LENGTH = 8 describe('Balance pagination tests', () => { - before(() => { - cy.clearLocalStorage() + it('Verify a user can change rows per page and navigate to next and previous page', () => { cy.visit(constants.BALANCE_URL + constants.SEPOLIA_TEST_SAFE_6) - main.acceptCookies() assets.selectTokenList(assets.tokenListOptions.allTokens) - }) - - it('Verify a user can change rows per page and navigate to next and previous page', () => { assets.verifyInitialTableState() assets.changeTo10RowsPerPage() assets.verifyTableHas10Rows() diff --git a/apps/web/cypress/e2e/regression/batch_tx.cy.js b/apps/web/cypress/e2e/regression/batch_tx.cy.js new file mode 100644 index 000000000..a8055fcef --- /dev/null +++ b/apps/web/cypress/e2e/regression/batch_tx.cy.js @@ -0,0 +1,114 @@ +import * as batch from '../pages/batches.pages' +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../../e2e/pages/owners.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as ls from '../../support/localstorage_data.js' +import * as navigation from '../pages/navigation.page.js' +import { getMockAddress } from '../../support/utils/ethers.js' + +const currentNonce = 3 +const funds_first_tx = '0.001' +const funds_second_tx = '0.002' + +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 + +describe('Batch transaction tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_2) + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + }) + + it('Verify the Add batch button is present in a transaction form', () => { + //The "true" is to validate that the add to batch button is not visible if "Yes, execute" is selected + batch.addNewTransactionToBatch(getMockAddress(), currentNonce, funds_first_tx) + }) + + it('Verify a second transaction can be added to the batch', () => { + batch.addNewTransactionToBatch(getMockAddress(), currentNonce, funds_first_tx) + cy.wait(1000) + batch.addNewTransactionToBatch(getMockAddress(), currentNonce, funds_first_tx) + batch.verifyBatchIconCount(2) + batch.clickOnBatchCounter() + batch.verifyAmountTransactionsInBatch(2) + }) + + it('Verify that clicking on "Confirm batch" button opens confirm batch modal with listed transactions', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__batch, ls.batchData.entry0) + cy.reload() + batch.clickOnBatchCounter() + batch.clickOnConfirmBatchBtn() + cy.contains(funds_first_tx).parents('ul').as('TransactionList') + cy.get('@TransactionList').find('li').eq(0).contains(funds_first_tx) + cy.get('@TransactionList').find('li').eq(1).contains(funds_second_tx) + cy.contains(batch.addToBatchBtn).should('have.length', 0) + }) + + it('Verify the "New transaction" button in Add batch modal is enabled/disabled for different users types', () => { + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer) + batch.openBatchtransactionsModal() + batch.verifyNewTxButtonStatus(constants.enabledStates.enabled) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer2) + owner.waitForConnectionStatus() + batch.verifyNewTxButtonStatus(constants.enabledStates.disabled) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + batch.verifyNewTxButtonStatus(constants.enabledStates.disabled) + }) + + it('Verify a batched tx can be expanded and collapsed', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__batch, ls.batchData.entry0) + cy.reload() + batch.clickOnBatchCounter() + cy.contains(funds_first_tx).parents('ul').as('TransactionList') + cy.get('@TransactionList').find('li').eq(0).contains(funds_first_tx).click() + batch.isTxExpanded(0, true) + cy.get('@TransactionList').find('li').eq(0).contains(funds_first_tx).click() + batch.isTxExpanded(0, false) + }) + + it('Verify that the Add batch button is not present on non-safe pages', () => { + const urls = [constants.welcomeUrl, constants.appSettingsUrl, constants.appsUrl] + + urls.forEach((url) => { + cy.visit(url) + cy.get(batch.batchTxTopBar).should('not.exist') + }) + }) + + it('Verify a transaction can be added to the batch', () => { + wallet.connectSigner(signer) + batch.addNewTransactionToBatch(constants.EOA, currentNonce, funds_first_tx) + batch.verifyBatchIconCount(1) + batch.clickOnBatchCounter() + batch.verifyAmountTransactionsInBatch(1) + }) + + it('Verify a transaction can be removed from the batch', () => { + cy.wrap(null) + .then(() => main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__batch, ls.batchData.entry0)) + .then(() => main.isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__batch, ls.batchData.entry0)) + .then(() => { + cy.reload() + wallet.connectSigner(signer) + batch.clickOnBatchCounter() + cy.contains(batch.batchedTransactionsStr).should('be.visible').parents('aside').find('ul > li').as('BatchList') + cy.get('@BatchList').find(batch.deleteTransactionbtn).eq(0).click() + cy.get('@BatchList').should('have.length', 1) + cy.get('@BatchList').contains(funds_first_tx).should('not.exist') + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/bulk_execution.cy.js b/apps/web/cypress/e2e/regression/bulk_execution.cy.js new file mode 100644 index 000000000..12aa9b6cf --- /dev/null +++ b/apps/web/cypress/e2e/regression/bulk_execution.cy.js @@ -0,0 +1,86 @@ +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 data from '../../fixtures/txhistory_data_data.json' + +let staticSafes, + fundsSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +const typeBulkTx = data.type.bulkTransaction + +describe('Bulk execution', () => { + before(() => { + getSafes(CATEGORIES.funds) + .then((funds) => { + fundsSafes = funds + return getSafes(CATEGORIES.static) + }) + .then((statics) => { + staticSafes = statics + }) + }) + + it('Verify that Bulk Execution is available for a few fully signed txs located one by one', () => { + cy.visit(constants.transactionQueueUrl + fundsSafes.SEP_FUNDS_SAFE_14) + main.acceptCookies() + wallet.connectSigner(signer) + create_tx.verifyBulkExecuteBtnIsEnabled(2) + create_tx.verifyEnabledBulkExecuteBtnTooltip() + }) + + it( + 'Verify that "Confirm bulk execution" screen contains only available for execution txs in the actions list', + { defaultCommandTimeout: 30000 }, + () => { + const actions = ['1transfer', '2removeOwner'] + + cy.visit(constants.transactionQueueUrl + fundsSafes.SEP_FUNDS_SAFE_14) + wallet.connectSigner(signer) + main.acceptCookies() + create_tx.verifyBulkExecuteBtnIsEnabled(2).click() + create_tx.verifyBulkConfirmationScreen(2, actions) + }, + ) + + it( + 'Verify bulk view for the txs with the same tx hash in the History (tx executed via bulk feature)', + { defaultCommandTimeout: 30000 }, + () => { + const actions = ['Wrapped Ether', 'addOwnerWithThreshold', 'Sent'] + const tx = '3 transactions' + + cy.visit(constants.transactionsHistoryUrl + fundsSafes.SEP_FUNDS_SAFE_14) + wallet.connectSigner(signer) + main.acceptCookies() + create_tx.verifyBulkTxHistoryBlock(create_tx.bulkTxs, tx, actions) + }, + ) + + it( + 'Verify bulk view for the outgoing and incoming txs in the History after swap', + { defaultCommandTimeout: 30000 }, + () => { + const data = [typeBulkTx.receive, typeBulkTx.send, typeBulkTx.COW, typeBulkTx.DAI] + const tx = typeBulkTx.twoTx + + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_1) + main.acceptCookies() + create_tx.toggleUntrustedTxs() + create_tx.verifyBulkTxHistoryBlock(create_tx.swapOrder, tx, data) + }, + ) + + it( + 'Verify that Bulk Execution button is disabled if the tx in Next is not fully signed', + { defaultCommandTimeout: 30000 }, + () => { + cy.visit(constants.transactionQueueUrl + fundsSafes.SEP_FUNDS_SAFE_15) + main.acceptCookies() + create_tx.verifyBulkExecuteBtnIsDisabled() + }, + ) +}) diff --git a/apps/web/cypress/e2e/regression/create_safe_cf.cy.js b/apps/web/cypress/e2e/regression/create_safe_cf.cy.js new file mode 100644 index 000000000..bca5f989a --- /dev/null +++ b/apps/web/cypress/e2e/regression/create_safe_cf.cy.js @@ -0,0 +1,71 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as createwallet from '../pages/create_wallet.pages' +import * as owner from '../pages/owners.pages' +import * as ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +// DO NOT use OWNER_2_PRIVATE_KEY for safe creation. Used for CF safes. +const signer = walletCredentials.OWNER_2_PRIVATE_KEY + +describe('CF Safe regression tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_0) + }) + + it('Verify "0 out of 2 step completed" is shown in the dashboard', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + cy.reload() + createwallet.checkInitialStepsDisplayed() + }) + + it('Verify "Add native assets" button opens a modal with a QR code and the safe address', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + cy.reload() + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + createwallet.clickOnAddFundsBtn() + main.verifyElementsIsVisible([createwallet.qrCode, createwallet.addressInfo]) + }) + + it('Verify QR code switch status change works in "Add native assets" modal', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + cy.reload() + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + createwallet.clickOnAddFundsBtn() + createwallet.checkQRCodeSwitchStatus(constants.checkboxStates.checked) + createwallet.clickOnQRCodeSwitch() + createwallet.checkQRCodeSwitchStatus(constants.checkboxStates.unchecked) + }) + + it('Verify "Notifications" in the settings are disabled', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + cy.reload() + cy.visit(constants.notificationsUrl + staticSafes.SEP_STATIC_SAFE_0) + createwallet.checkNotificationsSwitchIs(constants.enabledStates.disabled) + }) + + it('Verify in assets, that a "Add funds" block is present', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + cy.reload() + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_0) + main.verifyElementsIsVisible([createwallet.addFundsSection, createwallet.noTokensAlert]) + }) + + it('Verify clicking on "Activate now" button opens safe activation flow', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_0) + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + createwallet.clickOnActivateAccountBtn(1) + cy.contains(createwallet.deployWalletStr) + }) +}) diff --git a/apps/web/cypress/e2e/regression/create_safe_simple.cy.js b/apps/web/cypress/e2e/regression/create_safe_simple.cy.js new file mode 100644 index 000000000..3f896e918 --- /dev/null +++ b/apps/web/cypress/e2e/regression/create_safe_simple.cy.js @@ -0,0 +1,145 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as createwallet from '../pages/create_wallet.pages' +import * as owner from '../pages/owners.pages' +import * as ls from '../../support/localstorage_data.js' +import * as wallet from '../../support/utils/wallet.js' +import { getMockAddress } from '../../support/utils/ethers.js' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Safe creation tests', () => { + beforeEach(() => { + cy.visit(constants.welcomeUrl + '?chain=sep') + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + }) + + // TODO: Check unit tests + it('Verify error message is displayed if wallet name input exceeds 50 characters', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.typeWalletName(main.generateRandomString(51)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.exceedChars) + createwallet.clearWalletName() + }) + + // TODO: Replace wallet with Safe + // TODO: Check unit tests + it('Verify there is no error message is displayed if wallet name input contains less than 50 characters', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.typeWalletName(main.generateRandomString(50)) + owner.verifyValidWalletName(constants.addressBookErrrMsg.exceedChars) + }) + + it('Verify current connected account is shown as default owner', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clickOnNextBtn() + owner.verifyExistingOwnerAddress(0, constants.DEFAULT_OWNER_ADDRESS) + }) + + // TODO: Check unit tests + it('Verify error message is displayed if owner name input exceeds 50 characters', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + owner.typeExistingOwnerName(main.generateRandomString(51)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.exceedChars) + }) + + // TODO: Check unit tests + it('Verify there is no error message is displayed if owner name input contains less than 50 characters', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + owner.typeExistingOwnerName(main.generateRandomString(50)) + owner.verifyValidWalletName(constants.addressBookErrrMsg.exceedChars) + }) + + it('Verify data persistence', () => { + const ownerName = 'David' + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clickOnNextBtn() + createwallet.clickOnAddNewOwnerBtn() + createwallet.typeOwnerName(ownerName, 1) + createwallet.typeOwnerAddress(constants.SEPOLIA_OWNER_2, 1) + createwallet.clickOnBackBtn() + createwallet.clearWalletName() + createwallet.typeWalletName(createwallet.walletName) + createwallet.clickOnNextBtn() + createwallet.clickOnNextBtn() + createwallet.verifySafeNameInSummaryStep(createwallet.walletName) + createwallet.verifyOwnerNameInSummaryStep(ownerName) + createwallet.verifyOwnerAddressInSummaryStep(constants.DEFAULT_OWNER_ADDRESS) + createwallet.verifyOwnerAddressInSummaryStep(constants.DEFAULT_OWNER_ADDRESS) + createwallet.verifyThresholdStringInSummaryStep(1, 2) + createwallet.verifySafeNetworkNameInSummaryStep(constants.networks.sepolia.toLowerCase()) + createwallet.clickOnBackBtn() + createwallet.clickOnBackBtn() + cy.wait(1000) + createwallet.clickOnNextBtn() + createwallet.clickOnNextBtn() + createwallet.verifySafeNameInSummaryStep(createwallet.walletName) + createwallet.verifyOwnerNameInSummaryStep(ownerName) + createwallet.verifyOwnerAddressInSummaryStep(constants.DEFAULT_OWNER_ADDRESS) + createwallet.verifyOwnerAddressInSummaryStep(constants.DEFAULT_OWNER_ADDRESS) + createwallet.verifyThresholdStringInSummaryStep(1, 2) + createwallet.verifySafeNetworkNameInSummaryStep(constants.networks.sepolia.toLowerCase()) + }) + + it('Verify tip is displayed on right side for threshold 1/1', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clickOnNextBtn() + createwallet.verifyPolicy1_1() + }) + + // TODO: Check unit tests + it('Verify address input validation rules', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clickOnNextBtn() + createwallet.clickOnAddNewOwnerBtn() + createwallet.typeOwnerAddress(main.generateRandomString(10), 1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidFormat) + + createwallet.typeOwnerAddress(constants.DEFAULT_OWNER_ADDRESS, 1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownerAdded) + + createwallet.typeOwnerAddress(getMockAddress().replace('A', 'a'), 1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + createwallet.typeOwnerAddress(constants.ENS_TEST_SEPOLIA_INVALID, 1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.failedResolve) + }) + + it('Verify duplicated signer error using the autocomplete feature', () => { + cy.visit(constants.createNewSafeSepoliaUrl + '?chain=sep') + cy.wrap(null) + .then(() => + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sameOwnerName), + ) + .then(() => { + cy.reload() + createwallet.waitForConnectionMsgDisappear() + createwallet.selectMultiNetwork(1, constants.networks.sepolia.toLowerCase()) + createwallet.clickOnNextBtn() + createwallet.clickOnAddNewOwnerBtn() + createwallet.clickOnSignerAddressInput(1) + main.verifyMinimumElementsCount(createwallet.addressAutocompleteOptions, 2) + createwallet.selectSignerOnAutocomplete(2) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownerAdded) + }) + }) + + // Unskip when the bug is fixed + it.skip('Verify Next button is disabled until switching to network is done', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.verifyNextBtnIsEnabled() + createwallet.clearNetworkInput(1) + createwallet.verifyNextBtnIsDisabled() + }) +}) diff --git a/cypress/e2e/regression/create_safe_simple_2.cy.js b/apps/web/cypress/e2e/regression/create_safe_simple_2.cy.js similarity index 99% rename from cypress/e2e/regression/create_safe_simple_2.cy.js rename to apps/web/cypress/e2e/regression/create_safe_simple_2.cy.js index f0ad5bf2b..0a10dda33 100644 --- a/cypress/e2e/regression/create_safe_simple_2.cy.js +++ b/apps/web/cypress/e2e/regression/create_safe_simple_2.cy.js @@ -15,8 +15,6 @@ const signer = walletCredentials.OWNER_4_PRIVATE_KEY describe('Safe creation tests 2', () => { beforeEach(() => { cy.visit(constants.welcomeUrl + '?chain=sep') - cy.clearLocalStorage() - main.acceptCookies() }) it('Cancel button cancels safe creation', () => { diff --git a/apps/web/cypress/e2e/regression/create_safe_simple_3.cy.js b/apps/web/cypress/e2e/regression/create_safe_simple_3.cy.js new file mode 100644 index 000000000..1b1314087 --- /dev/null +++ b/apps/web/cypress/e2e/regression/create_safe_simple_3.cy.js @@ -0,0 +1,68 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as createwallet from '../pages/create_wallet.pages.js' +import * as owner from '../pages/owners.pages.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 + +describe('Safe creation tests 3', () => { + beforeEach(() => { + cy.visit(constants.welcomeUrl + '?chain=sep') + }) + it('Verify a Wallet can be connected', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + owner.clickOnWalletExpandMoreIcon() + owner.clickOnDisconnectBtn() + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + }) + + it('Verify that a new Wallet has default name related to the selected network', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.verifyDefaultWalletName(createwallet.defaultSepoliaPlaceholder) + }) + + it('Verify Add and Remove Owner Row works as expected', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clickOnNextBtn() + createwallet.clickOnAddNewOwnerBtn() + owner.verifyNumberOfOwners(2) + owner.verifyExistingOwnerAddress(1, '') + owner.verifyExistingOwnerName(1, '') + createwallet.removeOwner(0) + main.verifyElementsCount(createwallet.removeOwnerBtn, 0) + createwallet.clickOnAddNewOwnerBtn() + owner.verifyNumberOfOwners(2) + }) + + it('Verify Threshold Setup', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clickOnNextBtn() + createwallet.clickOnAddNewOwnerBtn() + createwallet.clickOnAddNewOwnerBtn() + owner.verifyNumberOfOwners(3) + createwallet.clickOnAddNewOwnerBtn() + owner.verifyNumberOfOwners(4) + owner.verifyThresholdLimit(1, 4) + createwallet.updateThreshold(3) + createwallet.removeOwner(1) + owner.verifyThresholdLimit(1, 3) + createwallet.removeOwner(1) + owner.verifyThresholdLimit(1, 2) + createwallet.updateThreshold(1) + }) +}) diff --git a/apps/web/cypress/e2e/regression/create_tx.cy.js b/apps/web/cypress/e2e/regression/create_tx.cy.js new file mode 100644 index 000000000..e5afce8cb --- /dev/null +++ b/apps/web/cypress/e2e/regression/create_tx.cy.js @@ -0,0 +1,53 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as createtx from '../../e2e/pages/create_tx.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as tx from '../../e2e/pages/transactions.page' + +let staticSafes = [] + +const sendValue = 0.00002 + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +function happyPathToStepTwo() { + createtx.typeRecipientAddress(constants.EOA) + createtx.clickOnTokenselectorAndSelectSepoliaEth() + createtx.setSendValue(sendValue) + createtx.clickOnNextBtn() +} + +describe('Create transactions tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + // Added to prod + it('Verify submitting a tx and that clicking on notification shows the transaction in queue', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + happyPathToStepTwo() + createtx.verifySubmitBtnIsEnabled() + createtx.changeNonce(14) + cy.wait(1000) + createtx.clickOnSignTransactionBtn() + createtx.clickViewTransaction() + createtx.verifySingleTxPage() + createtx.verifyQueueLabel() + createtx.verifyTransactionSummary(sendValue) + }) + + it('Verify relay is available on tx execution', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.BALANCE_URL + safe) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + happyPathToStepTwo() + cy.contains(tx.relayRemainingAttemptsStr).should('exist') + }) +}) diff --git a/cypress/e2e/smoke/create_tx_2.cy.js b/apps/web/cypress/e2e/regression/create_tx_2.cy.js similarity index 82% rename from cypress/e2e/smoke/create_tx_2.cy.js rename to apps/web/cypress/e2e/regression/create_tx_2.cy.js index 7030235f6..2753dcdcf 100644 --- a/cypress/e2e/smoke/create_tx_2.cy.js +++ b/apps/web/cypress/e2e/regression/create_tx_2.cy.js @@ -1,5 +1,4 @@ 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' @@ -18,21 +17,19 @@ function happyPathToStepTwo() { createtx.clickOnNextBtn() } -describe('[SMOKE] Create transactions tests 2', () => { +describe('Create transactions tests 2', () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) }) beforeEach(() => { - cy.clearLocalStorage() cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) - main.acceptCookies() wallet.connectSigner(signer) createtx.clickOnNewtransactionBtn() createtx.clickOnSendTokensBtn() }) - it('[SMOKE] Verify advance parameters gas limit input', () => { + it('Verify advance parameters gas limit input', () => { happyPathToStepTwo() createtx.changeNonce('1') createtx.selectCurrentWallet() @@ -40,7 +37,7 @@ describe('[SMOKE] Create transactions tests 2', () => { createtx.verifyAndSubmitExecutionParams() }) - it('[SMOKE] Verify a transaction shows relayer and addToBatch button', () => { + it('Verify a transaction shows relayer and addToBatch button', () => { happyPathToStepTwo() createtx.verifySubmitBtnIsEnabled() createtx.verifyNativeTokenTransfer() diff --git a/apps/web/cypress/e2e/regression/dashboard.cy.js b/apps/web/cypress/e2e/regression/dashboard.cy.js new file mode 100644 index 000000000..2307e446d --- /dev/null +++ b/apps/web/cypress/e2e/regression/dashboard.cy.js @@ -0,0 +1,39 @@ +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 { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +const txData = ['14', 'Send', '-0.00002 ETH', '1 out of 1'] + +describe('Dashboard tests', { defaultCommandTimeout: 20000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + 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) + }) + + it('Verify clicking on any tx takes the user to Transactions > Queue tab', () => { + dashboard.clickOnTxByIndex(0) + dashboard.verifySingleTxItem(txData) + }) +}) diff --git a/apps/web/cypress/e2e/regression/import_export_data_2.cy.js b/apps/web/cypress/e2e/regression/import_export_data_2.cy.js new file mode 100644 index 000000000..8366ac240 --- /dev/null +++ b/apps/web/cypress/e2e/regression/import_export_data_2.cy.js @@ -0,0 +1,62 @@ +import 'cypress-file-upload' +import * as file from '../pages/import_export.pages.js' +import * as constants from '../../support/constants.js' +import * as sidebar from '../pages/sidebar.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +const validJsonPath = 'cypress/fixtures/data_import.json' +const invalidJsonPath = 'cypress/fixtures/address_book_test.csv' +const invalidJsonPath_2 = 'cypress/fixtures/balances.json' +const invalidJsonPath_3 = 'cypress/fixtures/test-empty-batch.json' + +const appNames = ['Transaction Builder'] + +describe('Import Export Data tests 2', { defaultCommandTimeout: 20000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_13) + }) + + it('Verify that the Sidebar Import button opens an import modal', () => { + sidebar.openSidebar() + sidebar.clickOnSidebarImportBtn() + }) + + it('Verify that correctly formatted json file can be uploaded and shows data', () => { + sidebar.openSidebar() + sidebar.clickOnSidebarImportBtn() + file.dragAndDropFile(validJsonPath) + file.verifyImportMessages() + file.verifyImportBtnStatus(constants.enabledStates.enabled) + file.clickOnImportBtn() + cy.visit(constants.addressBookUrl + staticSafes.SEP_STATIC_SAFE_13) + file.verifyImportedAddressBookData() + cy.visit(constants.appsUrlGeneral + staticSafes.SEP_STATIC_SAFE_13) + file.verifyPinnedApps(appNames) + }) + + it('Verify that only json files can be imported', () => { + sidebar.openSidebar() + sidebar.clickOnSidebarImportBtn() + file.dragAndDropFile(invalidJsonPath) + file.verifyErrorOnUpload() + file.verifyImportBtnStatus(constants.enabledStates.disabled) + }) + + it('Verify that json files with wrong information are rejected', () => { + sidebar.openSidebar() + sidebar.clickOnSidebarImportBtn() + file.dragAndDropFile(invalidJsonPath_3) + file.verifyUploadErrorMessage(file.importErrorMessages.noImportableData) + file.clickOnCancelBtn() + sidebar.clickOnSidebarImportBtn() + file.dragAndDropFile(invalidJsonPath_2) + file.verifyUploadErrorMessage(file.importErrorMessages.noImportableData) + file.clickOnCancelBtn() + }) +}) diff --git a/apps/web/cypress/e2e/regression/limit_order.cy.js b/apps/web/cypress/e2e/regression/limit_order.cy.js new file mode 100644 index 000000000..faed8493e --- /dev/null +++ b/apps/web/cypress/e2e/regression/limit_order.cy.js @@ -0,0 +1,46 @@ +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 swaps_data from '../../fixtures/swaps_data.json' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +let staticSafes = [] + +let iframeSelector + +const swapsHistory = swaps_data.type.history +const swapOrder = swaps_data.type.orderDetails + +describe('Limit order tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify limit order confirmation details', { defaultCommandTimeout: 60000 }, () => { + const limitPrice = swaps.createRegex(swapOrder.DAIeqCOW, 'COW') + const widgetFee = swaps.getWidgetFee() + const orderID = swaps.getOrderID() + + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_27) + main.waitForHistoryCallToComplete() + wallet.connectSigner(signer) + iframeSelector = `iframe[src*="${constants.swapWidget}"]` + swaps.acceptLegalDisclaimer() + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToLimit() + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(500) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.setLimitExpiry(swaps.limitOrderExpiryOptions.five_minutes) + swaps.clickOnReviewOrderBtn() + swaps.placeLimitOrder() + }) + + swaps.verifyOrderDetails(limitPrice, swapOrder.expiry5Mins, 'i', swapOrder.interactWith, orderID, widgetFee) + }) +}) diff --git a/apps/web/cypress/e2e/regression/limit_order_history.cy.js b/apps/web/cypress/e2e/regression/limit_order_history.cy.js new file mode 100644 index 000000000..5871fbf87 --- /dev/null +++ b/apps/web/cypress/e2e/regression/limit_order_history.cy.js @@ -0,0 +1,43 @@ +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 swaps_data from '../../fixtures/swaps_data.json' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +let staticSafes = [] + +let iframeSelector + +const swapsHistory = swaps_data.type.history +const swapOrder = swaps_data.type.orderDetails + +describe('Limit order history tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify "Expired" field in the tx details for limit orders', { defaultCommandTimeout: 30000 }, () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_1 + swaps.swapTxs.sellLimitOrder) + const dai = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqCOW, 'COW') + + create_tx.verifyExpandedDetails([swapsHistory.sellOrder, swapsHistory.sell, dai, eq, swapsHistory.expired]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.gGpV2, swapsHistory.actionPreSignatureG]) + }) + + it('Verify "Filled" field in the tx details for limit orders', { 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.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/load_safe.cy.js b/apps/web/cypress/e2e/regression/load_safe.cy.js new file mode 100644 index 000000000..f766da9a2 --- /dev/null +++ b/apps/web/cypress/e2e/regression/load_safe.cy.js @@ -0,0 +1,38 @@ +import 'cypress-file-upload' +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' + +let staticSafes = [] + +const testSafeName = 'Test safe name' +const testOwnerName = 'Test Owner Name' + +describe('Load Safe tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.loadNewSafeSepoliaUrl) + cy.wait(2000) + }) + + it('Verify custom name in the first owner can be set', () => { + safe.inputNameAndAddress(testSafeName, staticSafes.SEP_STATIC_SAFE_4) + safe.clickOnNextBtn() + createwallet.typeOwnerName(testOwnerName, 0) + safe.clickOnNextBtn() + }) + + // Added to prod + it('Verify Safe and owner names are displayed in the Review step', () => { + safe.inputNameAndAddress(testSafeName, staticSafes.SEP_STATIC_SAFE_4) + safe.clickOnNextBtn() + createwallet.typeOwnerName(testOwnerName, 0) + safe.clickOnNextBtn() + safe.verifyDataInReviewSection(testSafeName, testOwnerName) + safe.clickOnAddBtn() + }) +}) diff --git a/cypress/e2e/regression/load_safe_2.cy.js b/apps/web/cypress/e2e/regression/load_safe_2.cy.js similarity index 96% rename from cypress/e2e/regression/load_safe_2.cy.js rename to apps/web/cypress/e2e/regression/load_safe_2.cy.js index 0cd807c43..e49afa6d1 100644 --- a/cypress/e2e/regression/load_safe_2.cy.js +++ b/apps/web/cypress/e2e/regression/load_safe_2.cy.js @@ -5,6 +5,7 @@ import * as safe from '../pages/load_safe.pages' import * as ls from '../../support/localstorage_data.js' import * as owner from '../pages/owners.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import { getMockAddress } from '../../support/utils/ethers.js' let staticSafes, fundSafes = [] @@ -26,9 +27,7 @@ describe('Load Safe tests 2', () => { }) beforeEach(() => { - cy.clearLocalStorage() cy.visit(constants.loadNewSafeSepoliaUrl) - main.acceptCookies() cy.wait(2000) }) @@ -93,7 +92,7 @@ describe('Load Safe tests 2', () => { }) it('Verify a valid address can be entered', () => { - safe.inputAddress(staticSafes.SEP_STATIC_SAFE_13) + safe.inputAddress(getMockAddress()) safe.verifyAddresFormatIsValid() }) @@ -109,7 +108,7 @@ describe('Load Safe tests 2', () => { }) it('Verify that the wrong prefix is not allowed', () => { - safe.inputAddress(fundSafes.ETH_FUNDS_SAFE_13) + safe.inputAddress(`eth:${getMockAddress()}`) owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.prefixMismatch) safe.verifyNextButtonStatus(constants.enabledStates.disabled) }) diff --git a/apps/web/cypress/e2e/regression/load_safe_3.cy.js b/apps/web/cypress/e2e/regression/load_safe_3.cy.js new file mode 100644 index 000000000..baae295b2 --- /dev/null +++ b/apps/web/cypress/e2e/regression/load_safe_3.cy.js @@ -0,0 +1,60 @@ +import 'cypress-file-upload' +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as safe from '../pages/load_safe.pages.js' +import * as createwallet from '../pages/create_wallet.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +const testSafeName = 'Test safe name' +const testOwnerName = 'Test Owner Name' + +describe('Load Safe tests - 3', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.loadNewSafeSepoliaUrl) + }) + + it('Verify that after loading existing Safe, its name input is not empty', () => { + safe.inputNameAndAddress(testSafeName, staticSafes.SEP_STATIC_SAFE_4) + safe.clickOnNextBtn() + safe.verifyOnwerInputIsNotEmpty(0) + }) + + it('Verify that when changing a network in dropdown, the same network is displayed in right top corner', () => { + safe.clickNetworkSelector(constants.networks.sepolia) + safe.selectPolygon() + cy.wait(1000) + safe.checkMainNetworkSelected(constants.networks.polygon) + }) + + it('Verify the custom Safe name is successfully loaded', () => { + safe.inputNameAndAddress(testSafeName, staticSafes.SEP_STATIC_SAFE_3) + safe.clickOnNextBtn() + createwallet.typeOwnerName(testOwnerName, 0) + safe.clickOnNextBtn() + safe.verifyDataInReviewSection( + testSafeName, + testOwnerName, + constants.commonThresholds.oneOfOne, + constants.networks.sepolia, + constants.SEPOLIA_OWNER_2, + ) + safe.clickOnAddBtn() + main.verifyHomeSafeUrl(staticSafes.SEP_STATIC_SAFE_3) + safe.veriySidebarSafeNameIsVisible(testSafeName) + safe.verifyOwnerNamePresentInSettings(testOwnerName) + }) + + it('Verify a network can be selected in the Safe', () => { + safe.clickNetworkSelector(constants.networks.sepolia) + safe.selectPolygon() + cy.wait(2000) + safe.clickNetworkSelector(constants.networks.polygon) + safe.selectSepolia() + }) +}) diff --git a/apps/web/cypress/e2e/regression/messages_offchain.cy.js b/apps/web/cypress/e2e/regression/messages_offchain.cy.js new file mode 100644 index 000000000..5b98aa1bf --- /dev/null +++ b/apps/web/cypress/e2e/regression/messages_offchain.cy.js @@ -0,0 +1,34 @@ +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 msg_confirmation_modal from '../pages/modals/message_confirmation.pages.js' +import * as wallet from '../../support/utils/wallet.js' + +let staticSafes = [] +const offchainMessage = 'Test message 2 off-chain' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY + +describe('Offchain Messages tests', () => { + before(async () => { + 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.clickOnMessageDetails() + msg_confirmation_modal.verifyOffchainMessageHash(0) + msg_confirmation_modal.verifyOffchainMessageHash(1) + msg_confirmation_modal.checkMessageInfobox() + }) +}) diff --git a/apps/web/cypress/e2e/regression/messages_onchain.cy.js b/apps/web/cypress/e2e/regression/messages_onchain.cy.js new file mode 100644 index 000000000..1d65df2a0 --- /dev/null +++ b/apps/web/cypress/e2e/regression/messages_onchain.cy.js @@ -0,0 +1,50 @@ +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 msg_data from '../../fixtures/txmessages_data.json' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +const typeMessagesOnchain = msg_data.type.onChain + +describe('Onchain Messages tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_10) + }) + + it('Verify exapanded details for signed on-chain message', () => { + createTx.clickOnTransactionItemByName(typeMessagesOnchain.contractName) + createTx.verifyExpandedDetails([typeMessagesOnchain.contractName, typeMessagesOnchain.delegateCall]) + }) + + it('Verify exapanded details for unsigned on-chain message', () => { + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_10) + createTx.clickOnTransactionItemByName(typeMessagesOnchain.contractName) + createTx.verifyExpandedDetails([typeMessagesOnchain.contractName, typeMessagesOnchain.delegateCall]) + }) + + it('Verify summary for unsigned on-chain message', () => { + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_10) + createTx.verifySummaryByName( + typeMessagesOnchain.contractName, + null, + [typeMessagesOnchain.oneOftwo, typeMessagesOnchain.signMessage], + typeMessagesOnchain.altImage, + ) + }) + + // Added to prod + it('Verify summary for signed on-chain message', () => { + createTx.verifySummaryByName( + typeMessagesOnchain.contractName, + null, + [(typeMessagesOnchain.success, typeMessagesOnchain.signMessage)], + typeMessagesOnchain.altImage, + ) + }) +}) diff --git a/apps/web/cypress/e2e/regression/messages_popup.cy.js b/apps/web/cypress/e2e/regression/messages_popup.cy.js new file mode 100644 index 000000000..cfa1aa1ab --- /dev/null +++ b/apps/web/cypress/e2e/regression/messages_popup.cy.js @@ -0,0 +1,95 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as modal from '../pages/modals.page.js' +import * as apps from '../pages/safeapps.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as ls from '../../support/localstorage_data.js' +import * as messages from '../pages/messages.pages.js' +import * as msg_confirmation_modal from '../pages/modals/message_confirmation.pages.js' + +let staticSafes = [] +const safeApp = 'Safe Test App' +const onchainMessage = 'Message 1' +let iframeSelector + +describe('Messages popup window tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.appsCustomUrl + staticSafes.SEP_STATIC_SAFE_10) + iframeSelector = `iframe[id="iframe-${constants.safeTestAppurl}"]` + }) + + it('Verify off-chain message popup window can be triggered', () => { + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__customSafeApps_11155111, + ls.customApps(constants.safeTestAppurl).safeTestApp, + ) + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__SafeApps__browserPermissions, + ls.appPermissions(constants.safeTestAppurl).grantedPermissions, + ) + cy.reload() + apps.clickOnApp(safeApp) + apps.clickOnOpenSafeAppBtn() + main.getIframeBody(iframeSelector).within(() => { + apps.triggetOffChainTx() + }) + msg_confirmation_modal.verifyConfirmationWindowTitle(modal.modalTitiles.confirmTx) + msg_confirmation_modal.verifySafeAppInPopupWindow(safeApp) + }) + + it('Verify on-chain message popup window can be triggered', () => { + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__customSafeApps_11155111, + ls.customApps(constants.safeTestAppurl).safeTestApp, + ) + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__SafeApps__browserPermissions, + ls.appPermissions(constants.safeTestAppurl).grantedPermissions, + ) + + cy.reload() + apps.clickOnApp(safeApp) + apps.clickOnOpenSafeAppBtn() + main.getIframeBody(iframeSelector).within(() => { + messages.enterOnchainMessage(onchainMessage) + apps.triggetOnChainTx() + }) + msg_confirmation_modal.verifyConfirmationWindowTitle(modal.modalTitiles.confirmMsg) + msg_confirmation_modal.verifySafeAppInPopupWindow(safeApp) + msg_confirmation_modal.verifyMessagePresent(onchainMessage) + }) + + it('Verify warning message is displayed when 0x0000000 is used as a message', () => { + const msgHash = '0x0000000' + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__customSafeApps_11155111, + ls.customApps(constants.safeTestAppurl).safeTestApp, + ) + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__SafeApps__browserPermissions, + ls.appPermissions(constants.safeTestAppurl).grantedPermissions, + ) + cy.reload() + apps.clickOnApp(safeApp) + apps.clickOnOpenSafeAppBtn() + main.getIframeBody(iframeSelector).within(() => { + apps.enterMessage(msgHash) + apps.triggetSignMsg() + }) + apps.verifyBlindSigningEnabled(true) + apps.clickOnBlindSigningOption() + cy.visit(constants.appsCustomUrl + staticSafes.SEP_STATIC_SAFE_10) + apps.clickOnApp(safeApp) + apps.clickOnOpenSafeAppBtn() + main.getIframeBody(iframeSelector).within(() => { + apps.enterMessage(msgHash) + apps.triggetSignMsg() + }) + apps.verifyBlindSigningEnabled(false) + apps.verifySignBtnDisabled() + }) +}) diff --git a/apps/web/cypress/e2e/regression/multichain_create_safe.cy.js b/apps/web/cypress/e2e/regression/multichain_create_safe.cy.js new file mode 100644 index 000000000..cf74dc459 --- /dev/null +++ b/apps/web/cypress/e2e/regression/multichain_create_safe.cy.js @@ -0,0 +1,87 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as wallet from '../../support/utils/wallet.js' +import * as createwallet from '../pages/create_wallet.pages' +import * as createtx from '../pages/create_tx.pages.js' +import * as tx from '../pages/transactions.page.js' +import * as owner from '../pages/owners.pages' +import { getMockAddress } from '../../support/utils/ethers.js' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Multichain safe creation tests', () => { + beforeEach(() => { + cy.visit(constants.welcomeUrl + '?chain=sep') + cy.wait(2000) + wallet.connectSigner(signer) + }) + + it('Verify that Pay now is not available for the multichain safe creation', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) + createwallet.clickOnNextBtn() + createwallet.clickOnNextBtn() + main.verifyElementsCount(createwallet.payNowExecMethod, 0) + }) + + it('Verify that Pay now is available for single safe creation', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clearNetworkInput(1) + createwallet.enterNetwork(1, constants.networks.polygon) + createwallet.clickOnNetwrokCheckbox() + createwallet.clickOnNextBtn() + createwallet.clickOnNextBtn() + main.verifyElementsCount(createtx.payNowExecMethod, 1) + }) + + it('Verify that Relay is available for one safe creation', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.clearNetworkInput(1) + createwallet.enterNetwork(1, constants.networks.polygon) + createwallet.clickOnNetwrokCheckbox() + createwallet.clickOnNextBtn() + createwallet.clickOnNextBtn() + tx.selectRelayOtion() + cy.contains(tx.relayRemainingAttemptsStr).should('exist') + }) + + it('Verify that multichain safe creation is available with 2/2 setup', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) + createwallet.clickOnNextBtn() + owner.clickOnAddSignerBtn() + owner.typeOwnerAddressCreateSafeStep(1, getMockAddress()) + owner.clickOnThresholdDropdown() + owner.getThresholdOptions().eq(1).click() + createwallet.clickOnNextBtn() + createwallet.clickOnReviewStepNextBtn() + createwallet.clickOnLetsGoBtn().then(() => { + let data = localStorage.getItem(constants.localStorageKeys.SAFE_v2__undeployedSafes) + createwallet.assertCFSafeThresholdAndSigners(constants.networkKeys.polygon, 2, 2, data) + createwallet.assertCFSafeThresholdAndSigners(constants.networkKeys.sepolia, 2, 2, data) + }) + }) + + it('Verify that multichain safe creation is available for 1/2 set up', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) + createwallet.clickOnNextBtn() + owner.clickOnAddSignerBtn() + owner.typeOwnerAddressCreateSafeStep(1, getMockAddress()) + owner.clickOnThresholdDropdown() + owner.getThresholdOptions().eq(0).click() + createwallet.clickOnNextBtn() + createwallet.clickOnReviewStepNextBtn() + createwallet.clickOnLetsGoBtn().then(() => { + let data = localStorage.getItem(constants.localStorageKeys.SAFE_v2__undeployedSafes) + createwallet.assertCFSafeThresholdAndSigners(constants.networkKeys.polygon, 1, 2, data) + createwallet.assertCFSafeThresholdAndSigners(constants.networkKeys.sepolia, 1, 2, data) + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/multichain_create_safe_flow.cy.js b/apps/web/cypress/e2e/regression/multichain_create_safe_flow.cy.js new file mode 100644 index 000000000..6af934a15 --- /dev/null +++ b/apps/web/cypress/e2e/regression/multichain_create_safe_flow.cy.js @@ -0,0 +1,58 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as wallet from '../../support/utils/wallet.js' +import * as createwallet from '../pages/create_wallet.pages.js' +import * as owner from '../pages/owners.pages.js' +import { getMockAddress } from '../../support/utils/ethers.js' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Multichain safe creation flow tests', () => { + beforeEach(() => { + cy.visit(constants.welcomeUrl + '?chain=sep') + cy.wait(2000) + wallet.connectSigner(signer) + }) + + it('Verify Review screen for multichain safe creation flow', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) + createwallet.clickOnNextBtn() + createwallet.clickOnNextBtn() + main.verifyElementsExist([ + createwallet.payNowLaterMessageBox, + createwallet.safeSetupOverview, + createwallet.networksLogoList, + createwallet.reviewStepOwnerInfo, + createwallet.reviewStepSafeName, + createwallet.reviewStepThreshold, + createwallet.reviewStepNextBtn, + ]) + createwallet.checkNetworkLogoInReviewStep([constants.networkKeys.polygon, constants.networkKeys.sepolia]) + }) + + it('Verify that selected networks are displayed in preview multichain safe', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) + createwallet.clickOnNextBtn() + createwallet.clickOnNextBtn() + createwallet.checkNetworkLogoInReviewStep([constants.networkKeys.polygon, constants.networkKeys.sepolia]) + }) + + it('Verify Success safe creation screen for multichain creation', () => { + createwallet.clickOnContinueWithWalletBtn() + createwallet.clickOnCreateNewSafeBtn() + createwallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) + createwallet.clickOnNextBtn() + owner.clickOnAddSignerBtn() + owner.typeOwnerAddressCreateSafeStep(1, getMockAddress()) + createwallet.clickOnNextBtn() + createwallet.clickOnReviewStepNextBtn() + main.verifyElementsExist([createwallet.cfSafeActivationMsg, createwallet.cfSafeCreationSuccessMsg]) + createwallet.checkNetworkLogoInSafeCreationModal([constants.networkKeys.polygon, constants.networkKeys.sepolia]) + createwallet.clickOnLetsGoBtn() + }) +}) diff --git a/apps/web/cypress/e2e/regression/multichain_network.cy.js b/apps/web/cypress/e2e/regression/multichain_network.cy.js new file mode 100644 index 000000000..8a1409209 --- /dev/null +++ b/apps/web/cypress/e2e/regression/multichain_network.cy.js @@ -0,0 +1,75 @@ +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 ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +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 + +describe('Multichain add network tests', { defaultCommandTimeout: 60000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.MATIC_STATIC_SAFE_28) + cy.wait(2000) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set5) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.multichain) + wallet.connectSigner(signer) + }) + + it('Verify CF safe can be created when adding a new network from more options menu', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'eth') + sideBar.openSidebar() + sideBar.addNetwork(constants.networks.ethereum) + cy.contains(sideBar.createSafeMsg(constants.networks.ethereum)) + cy.url().should('include', safe) + sideBar.checkUndeployedSafeExists(0) + cy.wrap(null, { timeout: 10000 }).then(() => { + cy.window().then((window) => { + const addressBook = JSON.parse(window.localStorage.getItem(constants.localStorageKeys.SAFE_v2__addressBook)) + + expect(addressBook).to.have.property('1') + expect(addressBook['1']).to.have.property( + staticSafes.MATIC_STATIC_SAFE_28.substring(6), + sideBar.multichainSafes.polygon, + ) + }) + }) + }) + + it('Verify that CF safe can be removed and re-added using "Add Network"', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.BALANCE_URL + safe) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set6_undeployed_safe) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.undeployed) + sideBar.openSidebar() + sideBar.removeSafeItem(sideBar.undeployedSafe) + cy.wrap(null, { timeout: 10000 }).should(() => { + expect(localStorage.getItem(constants.localStorageKeys.SAFE_v2__addressBook) === '{}').to.be.true + }) + sideBar.addNetwork(constants.networks.ethereum) + cy.contains(sideBar.createSafeMsg(constants.networks.ethereum)) + sideBar.checkUndeployedSafeExists(0) + }) + + it('Verify that "Add network" button in Add another network modal is disabled when network is not selected', () => { + sideBar.openSidebar() + sideBar.clickOnAddNetworkBtn() + sideBar.getModalAddNetworkBtn().should('be.disabled') + }) + + it('Verify that already added network can not be selected', () => { + sideBar.openSidebar() + sideBar.clickOnAddNetworkBtn() + sideBar.clickOnNetworkInput() + sideBar.getNetworkOptions().should('not.contain', constants.networks.sepolia) + sideBar.getModalAddNetworkBtn().should('be.disabled') + }) +}) diff --git a/apps/web/cypress/e2e/regression/multichain_networkswitch.cy.js b/apps/web/cypress/e2e/regression/multichain_networkswitch.cy.js new file mode 100644 index 000000000..b06c135d0 --- /dev/null +++ b/apps/web/cypress/e2e/regression/multichain_networkswitch.cy.js @@ -0,0 +1,99 @@ +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 ls from '../../support/localstorage_data.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 * as create_wallet from '../pages/create_wallet.pages.js' + +let staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +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 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.MATIC_STATIC_SAFE_28) + cy.wait(2000) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set5) + 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', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.BALANCE_URL + safe) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.addNetwork(constants.networks.ethereum) + cy.contains(sideBar.createSafeMsg(constants.networks.ethereum)) + 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, + ) + }) + + it('Verify that the selected network is already pre-selected in the "Add Another Network" pop-up and cannot be modified', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.BALANCE_URL + safe) + create_wallet.openNetworkSelector() + sideBar.clickOnShowAllNetworksBtn() + sideBar.checkNetworkPresence([constants.networks.gnosis], sideBar.addNetworkOption).click() + sideBar.checkNetworkIsNotEditable() + }) + + it('Verify Show all networks displays the full list of not added networks', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + 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) + }) + + it('Verify that test networks and main networks are splitted', () => { + create_wallet.openNetworkSelector() + sideBar.checkNetworksInRange(constants.networks.sepolia, 1, 'below') + sideBar.checkNetworksInRange(constants.networks.polygon, 1, 'above') + }) + + 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') + main.verifyElementsExist([sideBar.addNetworkTooltip]) + }) + + it('Verify that CF safe is created if other available network is selected from the "Show all networks"', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.BALANCE_URL + safe) + create_wallet.openNetworkSelector() + sideBar.clickOnShowAllNetworksBtn() + sideBar.checkNetworkPresence([constants.networks.ethereum], sideBar.addNetworkOption).click() + sideBar.getModalAddNetworkBtn().click() + sideBar.openSidebar() + sideBar.checkUndeployedSafeExists(0) + cy.wrap(null, { timeout: 10000 }).then(() => { + cy.window().then((window) => { + const addressBook = JSON.parse(window.localStorage.getItem(constants.localStorageKeys.SAFE_v2__addressBook)) + + expect(addressBook).to.have.property('1') + expect(addressBook['1']).to.have.property( + staticSafes.MATIC_STATIC_SAFE_28.substring(6), + sideBar.multichainSafes.sepolia, + ) + }) + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/multichain_setup.cy.js b/apps/web/cypress/e2e/regression/multichain_setup.cy.js new file mode 100644 index 000000000..4178f18cb --- /dev/null +++ b/apps/web/cypress/e2e/regression/multichain_setup.cy.js @@ -0,0 +1,108 @@ +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 ls from '../../support/localstorage_data.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 * as create_wallet from '../pages/create_wallet.pages.js' +import * as owner from '../pages/owners.pages.js' + +import { suspendOutreachModal } from '../pages/modals.page.js' + +let staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +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 setup tests', { defaultCommandTimeout: 60000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.MATIC_STATIC_SAFE_28) + cy.wait(2000) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set5) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.multichain) + wallet.connectSigner(signer) + }) + + it('Verify that batch tx with safe activation is not allowed for the CF safes', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'eth') + sideBar.openSidebar() + sideBar.addNetwork(constants.networks.ethereum) + cy.contains(sideBar.createSafeMsg(constants.networks.ethereum)) + sideBar.checkUndeployedSafeExists(0).click() + main.verifyElementsCount(navigation.newTxBtn, 0) + main.verifyElementsCount(create_wallet.activateAccountBtn, 2) + cy.visit(constants.setupUrl + safe) + owner.verifyAddOwnerBtnIsDisabled() + sideBar.verifyNavItemDisabled(sideBar.sideBarListItems[4]) + sideBar.verifyNavItemDisabled(sideBar.sideBarListItems[6]) + }) + + it('Verify notification if the owner set up was changed in original safe', () => { + sideBar.openSidebar() + sideBar.addNetwork(constants.networks.ethereum) + cy.contains(sideBar.createSafeMsg(constants.networks.ethereum)) + cy.visit(constants.homeUrl + staticSafes.MATIC_STATIC_SAFE_28) + sideBar.checkInconsistentSignersMsgDisplayed(constants.networks.ethereum) + }) + + 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() + sideBar.checkInconsistentSignersMsgDisplayedConfirmTxView(constants.networks.polygon) + }) + + it('Verify warning on remove owner for one safe in the group', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.setupUrl + safe) + + owner.waitForConnectionStatus() + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) + suspendOutreachModal() + owner.openRemoveOwnerWindow(1) + cy.wait(1000) + create_wallet.clickOnNextBtn() + sideBar.checkInconsistentSignersMsgDisplayedConfirmTxView(constants.networks.sepolia) + }) + + it('Verify warning on change policy for one safe in the group', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.setupUrl + safe) + owner.waitForConnectionStatus() + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) + suspendOutreachModal() + owner.clickOnChangeThresholdBtn() + create_wallet.updateThreshold(2) + owner.clickOnThresholdNextBtn() + sideBar.checkInconsistentSignersMsgDisplayedConfirmTxView(constants.networks.sepolia) + }) + + it('Verify warning on swap owner for one safe in the group', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + cy.visit(constants.setupUrl + safe) + owner.waitForConnectionStatus() + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) + suspendOutreachModal() + owner.openReplaceOwnerWindow(1) + owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) + cy.wait(2000) + owner.clickOnNextBtn() + sideBar.checkInconsistentSignersMsgDisplayedConfirmTxView(constants.networks.sepolia) + }) +}) diff --git a/apps/web/cypress/e2e/regression/multichain_sidebar.cy.js b/apps/web/cypress/e2e/regression/multichain_sidebar.cy.js new file mode 100644 index 000000000..4bfba0ff3 --- /dev/null +++ b/apps/web/cypress/e2e/regression/multichain_sidebar.cy.js @@ -0,0 +1,143 @@ +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 ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +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 +// DO NOT use OWNER_2_PRIVATE_KEY for safe creation. Used for CF safes. +const signer2 = walletCredentials.OWNER_2_PRIVATE_KEY + +describe('Multichain sidebar tests', { defaultCommandTimeout: 20000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.MATIC_STATIC_SAFE_28) + cy.wait(2000) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set5) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.multichain) + }) + + it('Verify Rename and Add network options are available for Group of safes', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.clickOnMultichainItemOptionsBtn(0) + main.verifyElementsIsVisible([sideBar.safeItemOptionsAddChainBtn, sideBar.safeItemOptionsRenameBtn]) + }) + + it('Verify Give name and Add network options are available for a deployed safe', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + wallet.connectSigner(signer) + cy.visit(constants.BALANCE_URL + safe) + + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + + sideBar.clickOnMultichainItemOptionsBtn(0) + main.verifyElementsIsVisible([sideBar.safeItemOptionsAddChainBtn, sideBar.safeItemOptionsRenameBtn]) + }) + + it('Verify Give name and Add network options are available for a CF safe', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_0) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set6_undeployed_safe) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + wallet.connectSigner(signer2) + cy.intercept('GET', constants.safeListEndpoint, {}) + sideBar.openSidebar() + sideBar.clickOnSafeItemOptionsBtnByIndex(0) + main.verifyElementsIsVisible([sideBar.safeItemOptionsRemoveBtn, sideBar.safeItemOptionsRenameBtn]) + }) + + it('Verify that removed from side bar CF safe is removed from the address book', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_0) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set6_undeployed_safe) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.undeployed) + wallet.connectSigner(signer2) + cy.intercept('GET', constants.safeListEndpoint, {}) + sideBar.openSidebar() + sideBar.removeSafeItem(sideBar.undeployedSafe) + cy.wrap(null, { timeout: 10000 }).should(() => { + expect(localStorage.getItem(constants.localStorageKeys.SAFE_v2__addressBook) === '{}').to.be.true + }) + }) + + it('Verify "Add network" in more options menu for the single safe', () => { + let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') + wallet.connectSigner(signer) + cy.visit(constants.BALANCE_URL + safe) + + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.clickOnSafeItemOptionsBtnByIndex(1) + sideBar.checkAddChainDialogDisplayed() + }) + + it('Verify "Add Networks" option for the group of safes with multi-chain safe', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.clickOnSafeItemOptionsBtnByIndex(0) + sideBar.checkAddChainDialogDisplayed() + }) + + it('Verify "Add another network" button in safe group', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + main.verifyElementsExist([sideBar.addNetworkBtn]) + }) + + it('Verify there is no Rename option for a safe in the group', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.checkThereIsNoOptionsMenu(0) + }) + + it('Verify Rename option in the group of safes opens a new edit entry modal', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.clickOnMultichainItemOptionsBtn(0) + sideBar.clickOnRenameBtn() + }) + it('Verify "Add another network" at the end of the group list', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.checkAddNetworkBtnPosition(0) + }) + + it('Verify balance of the safe group', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.checkSafeGroupBalance(0, '0.73') + }) + + it('Verify address of the safe group', () => { + const address = '0xC96e...ee3B' + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.checkSafeGroupAddress(0, address) + }) + + it('Verify network logo for safes in the group', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.checkSafeGroupIconsExist(0, 3) + }) + + it('Verify tooltip with networks for multichain safe', () => { + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.checkMultichainTooltipExists(0) + }) +}) diff --git a/apps/web/cypress/e2e/regression/nfts.cy.js b/apps/web/cypress/e2e/regression/nfts.cy.js new file mode 100644 index 000000000..fb848826f --- /dev/null +++ b/apps/web/cypress/e2e/regression/nfts.cy.js @@ -0,0 +1,105 @@ +import * as constants from '../../support/constants' +import * as nfts from '../pages/nfts.pages' +import * as navigation from '../pages/navigation.page' +import * as createTx 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' + +const singleNFT = ['safeTransferFrom'] +const multipleNFT = ['multiSend'] +const multipleNFTAction = 'safeTransferFrom' +const NFTSentName = 'GTT #22' + +let nftsSafes, + staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('NFTs tests', () => { + before(() => { + getSafes(CATEGORIES.nfts) + .then((nfts) => { + nftsSafes = nfts + return getSafes(CATEGORIES.static) + }) + .then((statics) => { + staticSafes = statics + }) + }) + + beforeEach(() => { + cy.visit(constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_2) + wallet.connectSigner(signer) + nfts.waitForNftItems(2) + }) + + // Added to prod + it('Verify multipls NFTs can be selected and reviewed', () => { + nfts.verifyInitialNFTData() + nfts.selectNFTs(3) + nfts.deselectNFTs([2], 3) + nfts.sendNFT() + nfts.verifyNFTModalData() + nfts.typeRecipientAddress(getMockAddress()) + nfts.clikOnNextBtn() + nfts.verifyReviewModalData(2) + }) + + it('Verify that when 1 NFTs is selected, there is no Actions block in Review step', () => { + nfts.verifyInitialNFTData() + nfts.selectNFTs(1) + nfts.sendNFT() + nfts.typeRecipientAddress(getMockAddress()) + nfts.clikOnNextBtn() + nfts.verifyTxDetails(singleNFT) + nfts.verifyCountOfActions(0) + }) + + // Added to prod + it('Verify that when 2 NFTs are selected, actions and tx details are correct in Review step', () => { + nfts.verifyInitialNFTData() + nfts.selectNFTs(2) + nfts.sendNFT() + nfts.typeRecipientAddress(getMockAddress()) + nfts.clikOnNextBtn() + nfts.verifyTxDetails(multipleNFT) + nfts.verifyCountOfActions(2) + nfts.verifyActionName(0, multipleNFTAction) + nfts.verifyActionName(1, multipleNFTAction) + }) + + // Added to prod + it('Verify Send button is disabled for non-owner', () => { + cy.visit(constants.balanceNftsUrl + nftsSafes.SEP_NFT_SAFE_2) + nfts.verifyInitialNFTData() + nfts.selectNFTs(1) + nfts.verifySendNFTBtnDisabled() + }) + + it('Verify Send button is disabled for disconnected wallet', () => { + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + nfts.selectNFTs(1) + nfts.verifySendNFTBtnDisabled() + }) + + // Added to prod + it('Verify Send NFT transaction has been created', () => { + cy.visit(constants.balanceNftsUrl + nftsSafes.SEP_NFT_SAFE_1) + wallet.connectSigner(signer) + nfts.verifyInitialNFTData() + nfts.selectNFTs(1) + nfts.sendNFT() + nfts.typeRecipientAddress(staticSafes.SEP_STATIC_SAFE_1) + createTx.changeNonce(2) + nfts.clikOnNextBtn() + createTx.clickOnSignTransactionBtn() + createTx.waitForProposeRequest() + createTx.clickViewTransaction() + createTx.verifySingleTxPage() + createTx.verifyQueueLabel() + createTx.verifyTransactionStrExists(NFTSentName) + }) +}) diff --git a/apps/web/cypress/e2e/regression/proposers.cy.js b/apps/web/cypress/e2e/regression/proposers.cy.js new file mode 100644 index 000000000..fb966b083 --- /dev/null +++ b/apps/web/cypress/e2e/regression/proposers.cy.js @@ -0,0 +1,98 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +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 navigation from '../pages/navigation.page.js' +import * as ls from '../../support/localstorage_data.js' +import * as proposer from '../pages/proposers.pages.js' +import { getMockAddress } from '../../support/utils/ethers.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY +const proposerAddress = 'sep:0xC16D...6fED' +const proposerAddress2 = '0x8eeC...2a3b' +const creatorAddress = 'sep:0xC16D...6fED' +const proposerName = 'Proposer 1' +const proposerNameAD = 'AD Proposer1' +const proposedTx = + '&id=multisig_0x09725D3c2f9bE905F8f9f1b11a771122cf9C9f35_0xd70f2f8b31ae98a7e3064f6cdb437e71d3df083a0709fb82c915fa82767a19eb' + +describe('Proposers tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_31) + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + wallet.connectSigner(signer) + }) + + it('Verify the proposers section on the Set up in the settings when there are no proposers', () => { + main.verifyElementsCount(proposer.proposersSection, 1) + }) + + it('Verify the "Add proposers" button is disabled for non-owner/disconnected users', () => { + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + proposer.verifyAddProposerBtnIsDisabled() + wallet.connectSigner(signer2) + proposer.verifyAddProposerBtnIsDisabled() + }) + + it('Verify that a proposer cannot be the safe itself', () => { + proposer.clickOnAddProposerBtn() + proposer.enterProposerData(staticSafes.SEP_STATIC_SAFE_31.substring(4), main.generateRandomString(5)) + proposer.checkSafeAsProposerErrorMessage() + }) + + it('Verify that a proposer address must be checksummed', () => { + proposer.clickOnAddProposerBtn() + proposer.enterProposerData(getMockAddress().replace('A', 'a'), main.generateRandomString(5)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + }) + + it('Verify a proposer Creator is shown in the table', () => { + proposer.checkCreatorAddress([creatorAddress]) + }) + + it('Verify non-creators of a proposers cannot edit or delete it', () => { + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer2) + proposer.verifyDeleteProposerBtnIsDisabled(proposerAddress) + proposer.verifyEditProposerBtnDisabled(proposerAddress) + }) + + it('Verify that the address book name of the proposers overwrites the name given during its creation', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.proposers) + cy.reload() + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + proposer.checkProposerData([proposerNameAD]) + }) + + it('Verify if the address book entry of propers name is removed, then the name given during its creation shows again', () => { + proposer.checkProposerData([proposerName]) + }) + + it('Verify Proposers cannot see the "Batched tx" button in the header', () => { + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer2) + proposer.verifyBatchDoesNotExist() + }) + + it('Verify a tx with the "proposal" status shows a message about being created by a proposer', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_31 + proposedTx) + proposer.verifyPropsalStatusExists() + proposer.verifyProposedTxMsgVisible() + }) + + it('Verify a tx with the "proposal" status shows the details of a proposer', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_31 + proposedTx) + proposer.verifyProposerInTxActionList(proposerAddress2) + }) +}) diff --git a/apps/web/cypress/e2e/regression/proposers_2.cy.js b/apps/web/cypress/e2e/regression/proposers_2.cy.js new file mode 100644 index 000000000..794d4fe54 --- /dev/null +++ b/apps/web/cypress/e2e/regression/proposers_2.cy.js @@ -0,0 +1,89 @@ +import * as constants from '../../support/constants.js' +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 createtx from '../pages/create_tx.pages.js' +import * as tx from '../pages/transactions.page.js' +import { getMockAddress } from '../../support/utils/ethers.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY +const signer3 = walletCredentials.OWNER_3_PRIVATE_KEY +const proposerAddress = '0x8eeC...2a3b' +const proposerAddress_2 = '0x0972...9f35' +const sendValue = 0.000001 + +describe('Proposers 2 tests', () => { + before(async () => { + 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 }) + wallet.connectSigner(signer2) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + createtx.typeRecipientAddress(getMockAddress()) + createtx.setSendValue(sendValue) + createtx.clickOnNextBtn() + createtx.verifySubmitBtnIsEnabled() + }) + + it('Verify a proposers cannot confirm a transaction', () => { + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_31) + wallet.connectSigner(signer2) + tx.verifyTxConfirmBtnDisabled() + }) + + it('Verify a proposer cannot edit himself', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_31) + wallet.connectSigner(signer2) + proposer.verifyEditProposerBtnDisabled(proposerAddress) + }) + + it('Verify a proposer cannot edit or remove other proposers', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_33) + wallet.connectSigner(signer2) + proposer.verifyEditProposerBtnDisabled(proposerAddress_2) + proposer.verifyDeleteProposerBtnIsDisabled(proposerAddress_2) + }) + + it('Verify that deleting a proposer is only possible by creator', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_33) + wallet.connectSigner(signer3) + proposer.verifyEditProposerBtnDisabled(proposerAddress_2) + proposer.verifyDeleteProposerBtnIsDisabled(proposerAddress_2) + proposer.verifyEditProposerBtnDisabled(proposerAddress) + proposer.verifyDeleteProposerBtnIsDisabled(proposerAddress) + }) + + //TODO: Unskip when tenderly visibilty bug is solved + it.skip('Verify a Tenderly simulation can be performed while proposing a tx', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_33) + wallet.connectSigner(signer2) + owner.openAddOwnerWindow() + owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) + owner.clickOnNextBtn() + createtx.clickOnSimulateTxBtn() + createtx.verifySuccessfulSimulation() + }) +}) diff --git a/apps/web/cypress/e2e/regression/recovery.cy.js b/apps/web/cypress/e2e/regression/recovery.cy.js new file mode 100644 index 000000000..311ac9f30 --- /dev/null +++ b/apps/web/cypress/e2e/regression/recovery.cy.js @@ -0,0 +1,225 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as owner from '../pages/owners.pages.js' +import * as recovery from '../pages/recovery.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as modules from '../pages/modules.page.js' +import * as navigation from '../pages/navigation.page.js' +import { getMockAddress } from '../../support/utils/ethers.js' + +let recoverySafes, + staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const guardian = walletCredentials.OWNER_2_PRIVATE_KEY + +describe('Recovery regression tests', { defaultCommandTimeout: 50000 }, () => { + before(() => { + getSafes(CATEGORIES.recovery) + .then((recoveries) => { + recoverySafes = recoveries + return getSafes(CATEGORIES.static) + }) + .then((statics) => { + staticSafes = statics + }) + }) + + it('Verify there is no account recovery section in the global settings', () => { + cy.visit(constants.setupUrl + recoverySafes.SEP_RECOVERY_SAFE_1) + cy.clearLocalStorage() + main.acceptCookies() + main.verifyElementsCount(recovery.setupRecoveryModalBtn, 0) + }) + + it('Verify that non-owner can not edit and delete recovery set up on Security and Login', () => { + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + main.acceptCookies() + recovery.verifyRecoveryTableDisplayed() + main.verifyElementsCount(recovery.removeRecovererBtn, 0) + main.verifyElementsCount(recovery.editRecovererBtn, 0) + }) + + it('Verify that non-owner can not delete recovery set up on Modules', () => { + cy.visit(constants.modulesUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + main.acceptCookies() + main.verifyElementsStatus([modules.moduleRemoveIcon], constants.enabledStates.disabled) + }) + + it('Verify that guardian can not delete or edit recovery set up on Security and Login', () => { + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(guardian) + main.acceptCookies() + recovery.postponeRecovery() + recovery.verifyRecoveryTableDisplayed() + main.verifyElementsCount(recovery.removeRecovererBtn, 0) + main.verifyElementsCount(recovery.editRecovererBtn, 0) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that during the first connection to the safe "Proposal to recover account" modal is displayed for the guardian', () => { + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(guardian) + main.acceptCookies() + recovery.verifyRecoveryProposalModalState(constants.elementExistanceStates.exist) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that "Account recovery" widget is displayed in the header for the Guardian', () => { + cy.visit(constants.homeUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(guardian) + main.acceptCookies() + recovery.clickOnRecoverLaterBtn() + recovery.verifyRecoveryProposalModalState(constants.elementExistanceStates.exist, true) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that recover later option is cached and "Proposal to account recovery" modal is not displayed on next safe opening', () => { + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(guardian) + main.acceptCookies() + recovery.clickOnRecoverLaterBtn() + cy.reload() + recovery.verifyRecoveryProposalModalState(constants.elementExistanceStates.not_exist) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that "Proposal to account recovery" modal is not displayed if the user is not guardian', () => { + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(signer) + main.acceptCookies() + recovery.verifyRecoveryProposalModalState(constants.elementExistanceStates.not_exist) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that the guardian can not delete recovery set up on Modules', () => { + cy.visit(constants.modulesUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(guardian) + main.acceptCookies() + recovery.postponeRecovery() + main.verifyElementsStatus([modules.moduleRemoveIcon], constants.enabledStates.disabled) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify initial and edited recovery settings', () => { + const address = '0x9445...F1BA' + const settings = [address, recovery.recoveryOptions.fiveSixDays, recovery.recoveryOptions.never] + const confirmationData = [recovery.recoveryOptions.fiveMin, recovery.recoveryOptions.oneHr] + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(signer) + main.acceptCookies() + recovery.verifyRecoveryTableDisplayed() + recovery.verifyRecovererSettings(settings) + recovery.clickOnEditRecoverer() + recovery.clickOnNextBtn() + recovery.setRecoveryDelay(recovery.recoveryOptions.fiveMin) + recovery.setRecoveryExpiry(recovery.recoveryOptions.oneHr) + recovery.agreeToTerms() + recovery.clickOnNextBtn() + recovery.verifyRecovererConfirmationData(confirmationData) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that set up recovery flow can be canceled before submitting tx', () => { + cy.visit(constants.securityUrl + staticSafes.SEP_STATIC_SAFE_13) + cy.clearLocalStorage() + wallet.connectSigner(signer) + main.acceptCookies() + recovery.clickOnSetupRecoveryBtn() + recovery.clickOnSetupRecoveryModalBtn() + recovery.clickOnNextBtn() + recovery.enterRecovererAddress(getMockAddress()) + recovery.agreeToTerms() + recovery.clickOnNextBtn() + navigation.clickOnModalCloseBtn(0) + recovery.getSetupRecoveryBtn() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify Recovery delay and Expiry options are present during recovery setup', () => { + const options = [ + recovery.recoveryOptions.customPeriod, + recovery.recoveryOptions.oneMin, + recovery.recoveryOptions.fiveMin, + recovery.recoveryOptions.oneHr, + recovery.recoveryOptions.twoDays, + recovery.recoveryOptions.sevenDays, + recovery.recoveryOptions.fourteenDays, + recovery.recoveryOptions.twentyEightDays, + recovery.recoveryOptions.fiveSixDays, + ] + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(signer) + main.acceptCookies() + recovery.verifyRecoveryTableDisplayed() + recovery.clickOnEditRecoverer() + recovery.clickOnNextBtn() + recovery.verifyRecoveryDelayOptions(options) + cy.get('body').click() + recovery.verifyRecoveryExpiryOptions(options.slice(1)) + cy.get('body').click() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that recovery tx is opened after clicking on "Start recovery" button in the widget', () => { + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(guardian) + main.acceptCookies() + recovery.clickOnRecoverLaterBtn() + cy.visit(constants.homeUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + recovery.clickOnStartRecoveryBtn() + recovery.enterRecovererAddress(getMockAddress()) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that the Security section contains Account recovery block on supported netwroks', () => { + const safes = [ + staticSafes.ETH_STATIC_SAFE_15, + staticSafes.GNO_STATIC_SAFE_16, + staticSafes.MATIC_STATIC_SAFE_17, + staticSafes.SEP_STATIC_SAFE_13, + ] + + safes.forEach((safe) => { + cy.visit(constants.prodbaseUrl + constants.securityUrl + safe) + recovery.getSetupRecoveryBtn() + }) + }) + + it('Verify that the Security and Login section does not contain Account recovery block on unsupported networks', () => { + const safes = [ + staticSafes.BNB_STATIC_SAFE_18, + staticSafes.AURORA_STATIC_SAFE_19, + staticSafes.AVAX_STATIC_SAFE_20, + staticSafes.LINEA_STATIC_SAFE_21, + staticSafes.ZKSYNC_STATIC_SAFE_22, + ] + + safes.forEach((safe) => { + cy.visit(constants.prodbaseUrl + constants.securityUrl + safe) + main.verifyElementsCount(recovery.setupRecoveryBtn, 0) + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/recovery_2.cy.js b/apps/web/cypress/e2e/regression/recovery_2.cy.js new file mode 100644 index 000000000..9482c80dc --- /dev/null +++ b/apps/web/cypress/e2e/regression/recovery_2.cy.js @@ -0,0 +1,81 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as owner from '../pages/owners.pages.js' +import * as recovery from '../pages/recovery.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as modules from '../pages/modules.page.js' +import * as navigation from '../pages/navigation.page.js' +import { getMockAddress } from '../../support/utils/ethers.js' + +let recoverySafes, + staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const guardian = walletCredentials.OWNER_2_PRIVATE_KEY + +describe('Recovery regression tests 2', { defaultCommandTimeout: 50000 }, () => { + before(() => { + getSafes(CATEGORIES.recovery) + .then((recoveries) => { + recoverySafes = recoveries + return getSafes(CATEGORIES.static) + }) + .then((statics) => { + staticSafes = statics + }) + }) + + it('Verify "Edit Recovery" flow start from the Recovery widget', () => { + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(signer) + main.acceptCookies() + recovery.verifyRecoveryTableDisplayed() + recovery.clickOnEditRecoverer() + recovery.verifyRecoveryModalDisplayed() + }) + + it('Verify that Recovery widget has "Edit recovery" button when the recovery module is enabled', () => { + cy.visit(constants.securityUrl + recoverySafes.SEP_RECOVERY_SAFE_4) + cy.clearLocalStorage() + wallet.connectSigner(signer) + main.acceptCookies() + recovery.verifyRecoveryTableDisplayed() + main.verifyElementsCount(recovery.editRecovererBtn, 1) + }) + + it('Verify that the "Set up recovery" button starts the set up recovery flow when no enabled recovery module in the safe', () => { + cy.visit(constants.securityUrl + staticSafes.SEP_STATIC_SAFE_13) + cy.clearLocalStorage() + wallet.connectSigner(signer) + main.acceptCookies() + recovery.clickOnSetupRecoveryBtn() + recovery.clickOnSetupRecoveryModalBtn() + recovery.verifyRecoveryModalDisplayed() + }) + + it('Verify that there is validation for the Guardian address field', () => { + cy.visit(constants.securityUrl + staticSafes.SEP_STATIC_SAFE_13) + cy.clearLocalStorage() + wallet.connectSigner(signer) + main.acceptCookies() + recovery.clickOnSetupRecoveryBtn() + recovery.clickOnSetupRecoveryModalBtn() + recovery.clickOnNextBtn() + + recovery.enterRecovererAddress(main.generateRandomString(10), 1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidFormat) + + recovery.enterRecovererAddress(getMockAddress().replace('A', 'a'), 1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + recovery.enterRecovererAddress(constants.ENS_TEST_SEPOLIA_INVALID, 1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.failedResolve) + + recovery.enterRecovererAddress(staticSafes.SEP_STATIC_SAFE_13, 1) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownSafeGuardian) + 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 new file mode 100644 index 000000000..928684d57 --- /dev/null +++ b/apps/web/cypress/e2e/regression/remove_owner.cy.js @@ -0,0 +1,67 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' +import * as createwallet from '../pages/create_wallet.pages' +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' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Remove Owners tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_13) + main.waitForHistoryCallToComplete() + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + }) + + it('Verify that "Remove" icon is visible', () => { + owner.verifyRemoveBtnIsEnabled().should('have.length', 2) + }) + + it('Verify remove button does not exist for Non-Owner when there is only 1 owner in the safe', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_3) + main.waitForHistoryCallToComplete() + main.verifyElementsCount(owner.removeOwnerBtn, 0) + }) + + it('Verify remove owner button is disabled for disconnected user', () => { + owner.verifyRemoveBtnIsDisabled() + }) + + it('Verify owner removal form can be opened', () => { + wallet.connectSigner(signer) + owner.openRemoveOwnerWindow(1) + }) + + it('Verify threshold input displays the upper limit as the current safe number of owners minus one', () => { + wallet.connectSigner(signer) + owner.openRemoveOwnerWindow(1) + owner.verifyThresholdLimit(1, 1) + owner.getThresholdOptions().should('have.length', 1) + }) + + // Added to prod + it('Verify owner deletion transaction has been created', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + owner.openRemoveOwnerWindow(1) + cy.wait(3000) + createwallet.clickOnNextBtn() + //This method creates the @removedAddress alias + owner.getAddressToBeRemoved() + owner.verifyOwnerDeletionWindowDisplayed() + createTx.changeNonce(10) + createTx.clickOnSignTransactionBtn() + createTx.waitForProposeRequest() + createTx.clickViewTransaction() + createTx.clickOnTransactionItemByName('removeOwner') + createTx.verifyTxDestinationAddress('@removedAddress') + }) +}) diff --git a/apps/web/cypress/e2e/regression/replace_owner.cy.js b/apps/web/cypress/e2e/regression/replace_owner.cy.js new file mode 100644 index 000000000..c9355ede9 --- /dev/null +++ b/apps/web/cypress/e2e/regression/replace_owner.cy.js @@ -0,0 +1,104 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' +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 ls from '../../support/localstorage_data.js' +import { getEvents, events, checkDataLayerEvents } from '../../support/utils/gtag.js' +import { getMockAddress } from '../../support/utils/ethers.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +const ownerName = 'Replacement Signer Name' + +describe('Replace Owners tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + }) + + it('Verify Tooltip displays correct message for disconnected user', () => { + owner.verifyReplaceBtnIsDisabled() + }) + + // TODO: Check unit tests + it('Verify max characters in name field', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow(0) + owner.typeOwnerName(main.generateRandomString(51)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.exceedChars) + }) + + it('Verify that Address input auto-fills with related value', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.autofillData) + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow(0) + owner.typeOwnerAddress(constants.addresBookContacts.user1.address) + owner.verifyNewOwnerName(constants.addresBookContacts.user1.name) + }) + + it('Verify that Name field not mandatory. Verify confirmation for owner replacement is displayed', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow(0) + owner.typeOwnerAddress(getMockAddress()) + owner.clickOnNextBtn() + owner.verifyConfirmTransactionWindowDisplayed() + }) + + it('Verify relevant error messages are displayed in Address input', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow(0) + owner.typeOwnerAddress(main.generateRandomString(10)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidFormat) + + owner.typeOwnerAddress(getMockAddress().toUpperCase()) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + owner.typeOwnerAddress(staticSafes.SEP_STATIC_SAFE_4) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownSafe) + + owner.typeOwnerAddress(getMockAddress().replace('A', 'a')) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + owner.typeOwnerAddress(constants.DEFAULT_OWNER_ADDRESS) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.alreadyAdded) + }) + + it("Verify 'Replace' tx is created. GA tx_created", () => { + const tx_created = [ + { + eventLabel: events.txCreatedSwapOwner.eventLabel, + eventCategory: events.txCreatedSwapOwner.category, + eventAction: events.txCreatedSwapOwner.action, + event: events.txCreatedSwapOwner.eventName, + safeAddress: staticSafes.SEP_STATIC_SAFE_25.slice(6), + }, + ] + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_25) + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow(1) + cy.wait(1000) + owner.typeOwnerName(ownerName) + owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) + createTx.changeNonce(0) + owner.clickOnNextBtn() + createTx.clickOnSignTransactionBtn() + createTx.clickViewTransaction() + createTx.verifyReplacedSigner(ownerName) + getEvents() + checkDataLayerEvents(tx_created) + }) +}) diff --git a/apps/web/cypress/e2e/regression/sidebar.cy.js b/apps/web/cypress/e2e/regression/sidebar.cy.js new file mode 100644 index 000000000..b973725dc --- /dev/null +++ b/apps/web/cypress/e2e/regression/sidebar.cy.js @@ -0,0 +1,64 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +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' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Sidebar tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_9) + }) + + it('Verify Current network is displayed at the top', () => { + sideBar.verifyNetworkIsDisplayed(constants.networks.sepolia) + }) + + // Added to prod + it('Verify current safe details', () => { + sideBar.verifySafeHeaderDetails(sideBar.testSafeHeaderDetails) + }) + + it('Verify QR button opens the QR code modal', () => { + sideBar.clickOnQRCodeBtn() + sideBar.verifyQRModalDisplayed() + }) + + it('Verify Open blockexplorer button contain etherscan link', () => { + sideBar.verifyEtherscanLinkExists() + }) + + // Added to prod + it('Verify New transaction button enabled for owners', () => { + wallet.connectSigner(signer) + sideBar.verifyNewTxBtnStatus(constants.enabledStates.enabled) + }) + + // Added to prod + it('Verify New transaction button enabled for beneficiaries who are non-owners', () => { + cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_11) + wallet.connectSigner(signer) + sideBar.verifyNewTxBtnStatus(constants.enabledStates.enabled) + }) + + // Added to prod + it('Verify New Transaction button disabled for non-owners', () => { + main.verifyElementsCount(navigation.newTxBtn, 0) + }) + + it('Verify the side menu buttons exist', () => { + sideBar.verifySideListItems() + }) + + it('Verify counter in the "Transaction" menu item if there are tx in the queue tab', () => { + sideBar.verifyTxCounter(1) + }) +}) diff --git a/cypress/e2e/regression/sidebar_2.cy.js b/apps/web/cypress/e2e/regression/sidebar_2.cy.js similarity index 77% rename from cypress/e2e/regression/sidebar_2.cy.js rename to apps/web/cypress/e2e/regression/sidebar_2.cy.js index a24156d4e..3800e48b0 100644 --- a/cypress/e2e/regression/sidebar_2.cy.js +++ b/apps/web/cypress/e2e/regression/sidebar_2.cy.js @@ -4,8 +4,11 @@ import * as sideBar from '../pages/sidebar.pages' import * as ls from '../../support/localstorage_data.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' let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY const newSafeName = 'Added safe 3' const addedSafe900 = 'Added safe 900' @@ -19,8 +22,6 @@ describe('Sidebar added sidebar tests', () => { beforeEach(() => { cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) cy.wait(2000) - cy.clearLocalStorage() - main.acceptCookies() main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set2) main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.addedSafes) }) @@ -37,7 +38,8 @@ describe('Sidebar added sidebar tests', () => { sideBar.verifySafeNameExists(newSafeName) }) - it('Verify a safe can be removed', () => { + // 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]) @@ -48,15 +50,9 @@ describe('Sidebar added sidebar tests', () => { sideBar.checkCurrencyInHeader(assets.currency$) }) - // Waiting for endpoint from CGW - it.skip('Verify "wallet" tag counter if the safe has tx ready for execution', () => { + it('Verify "wallet" tag counter if the safe has tx ready for execution', () => { + wallet.connectSigner(signer) sideBar.openSidebar() - sideBar.verifyMissingSignature(staticSafe200) - }) - - // Waiting for endpoint from CGW - it.skip('Verify "Wallet" tag counter only shows for owners', () => { - sideBar.openSidebar() - sideBar.verifyQueuedTx(staticSafe200) + sideBar.verifyNumberOfPendingTxTag(1) }) }) diff --git a/apps/web/cypress/e2e/regression/sidebar_3.cy.js b/apps/web/cypress/e2e/regression/sidebar_3.cy.js new file mode 100644 index 000000000..c76b27b04 --- /dev/null +++ b/apps/web/cypress/e2e/regression/sidebar_3.cy.js @@ -0,0 +1,61 @@ +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 create_wallet from '../pages/create_wallet.pages.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Sidebar tests 3', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify the empty state of the "All accounts" list', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.verifySafeListIsEmpty() + }) + + it('Verify the empty state of the pinned safes list', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, {}) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.verifyPinnedListIsEmpty() + }) + + it('Verify connected user is redirected from welcome page to accounts page', () => { + cy.visit(constants.welcomeUrl + '?chain=sep') + wallet.connectSigner(signer) + create_wallet.clickOnContinueWithWalletBtn() + + cy.location().should((loc) => { + expect(loc.pathname).to.eq('/welcome/accounts') + }) + }) + + it('Verify that the user see safes that he owns in the list', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.verifyAddedSafesExist([sideBar.sideBarSafes.safe1short, sideBar.sideBarSafes.safe2short]) + }) + + it('Verify there is an option to name an unnamed safe', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.verifySafeGiveNameOptionExists(0) + }) +}) diff --git a/apps/web/cypress/e2e/regression/sidebar_4.cy.js b/apps/web/cypress/e2e/regression/sidebar_4.cy.js new file mode 100644 index 000000000..7d022a1e4 --- /dev/null +++ b/apps/web/cypress/e2e/regression/sidebar_4.cy.js @@ -0,0 +1,67 @@ +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 ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +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 + +describe('Sidebar tests 4', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that safes in the sidebar show the "bookmark" icon', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.verifySafeBookmarkBtnExists(sideBar.sideBarSafes.safe1short) + }) + + it('Verify a safe can be added and removed from the pinned list', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.clickOnBookmarkBtn(sideBar.sideBarSafes.safe1short) + sideBar.verifyPinnedSafe(sideBar.sideBarSafes.safe1short) + sideBar.clickOnBookmarkBtn(sideBar.sideBarSafes.safe1short) + sideBar.verifyPinnedListIsEmpty() + }) + + it('Verify CF safe can be added and removed from the pinned list', () => { + 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.clickOnBookmarkBtn(sideBar.sideBarSafes.safe4short) + sideBar.verifyPinnedSafe(sideBar.sideBarSafes.safe4short) + sideBar.clickOnBookmarkBtn(sideBar.sideBarSafes.safe4short) + sideBar.verifyPinnedListIsEmpty() + }) + + it('Verify the "All account" list is always closed by whenever you open the sidebar', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1], + }) + wallet.connectSigner(signer) + sideBar.clickOnOpenSidebarBtn() + sideBar.verifyAccountsCollapsed() + }) + + it('Verify the empty state of Accounts shows "Connect" for a disconnected user', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + sideBar.openSidebar() + sideBar.verifyConnectBtnDisplayed() + }) +}) diff --git a/apps/web/cypress/e2e/regression/sidebar_5.cy.js b/apps/web/cypress/e2e/regression/sidebar_5.cy.js new file mode 100644 index 000000000..29f00f128 --- /dev/null +++ b/apps/web/cypress/e2e/regression/sidebar_5.cy.js @@ -0,0 +1,93 @@ +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 ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +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 + +describe('Sidebar search tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify the search input shows at the top above the pinned safes list', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + sideBar.openSidebar() + sideBar.verifySearchInputPosition() + }) + + it('Verify the search find safes in both pinned and unpinned safes', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.clickOnBookmarkBtn(sideBar.sideBarSafes.safe1short) + sideBar.searchSafe(sideBar.sideBarSafes.safe1short_) + sideBar.verifyAddedSafesExist([sideBar.sideBarSafes.safe1short]) + sideBar.verifySafeCount(1) + }) + + it("Verify searching for a safe name filters out those who don't match", () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.searchSafe(sideBar.sideBarSafes.safe1short_) + sideBar.verifyAddedSafesExist([sideBar.sideBarSafes.safe1short]) + sideBar.verifySafesDoNotExist([sideBar.sideBarSafes.safe2short]) + }) + + it('Verify searching for a safe also finds safes in different networks', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe3], + }) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) + wallet.connectSigner(signer) + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe(sideBar.sideBarSafes.multichain_short_) + sideBar.checkMultichainSubSafeExists([ + constants.networks.gnosis, + constants.networks.ethereum, + constants.networks.sepolia, + ]) + }) + + it('Verify search shows number of results found', () => { + const safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'eth') + cy.visit(constants.BALANCE_URL + safe) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2, sideBar.sideBarSafes.safe3], + }) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) + wallet.connectSigner(signer) + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('0x') + sideBar.checkSearchResults(3) + }) + + it('Verify clearing the search input returns back to the previous lists', () => { + const safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'eth') + cy.visit(constants.BALANCE_URL + safe) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2, sideBar.sideBarSafes.safe3], + }) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) + wallet.connectSigner(signer) + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('0xC') + sideBar.checkSearchResults(1) + sideBar.clearSearchInput() + sideBar.showAllSafes() + sideBar.verifyAccountListSafeCount(6) + }) +}) diff --git a/apps/web/cypress/e2e/regression/sidebar_6.cy.js b/apps/web/cypress/e2e/regression/sidebar_6.cy.js new file mode 100644 index 000000000..872dfd8cb --- /dev/null +++ b/apps/web/cypress/e2e/regression/sidebar_6.cy.js @@ -0,0 +1,86 @@ +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 ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +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 aSafe = 'Safe A' +const bSafe = 'Safe B' +const safe14 = 'Safe 14' +const safe15 = 'Safe 15' + +describe('Sidebar sorting tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('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) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) + wallet.connectSigner(signer) + cy.visit(constants.BALANCE_URL + safe_eth) + cy.visit(constants.BALANCE_URL + safe_gno) + + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('96') + sideBar.checkSearchResults(1) + sideBar.verifySafeCount(3) + sideBar.verifyAddedSafesExistByIndex(1, constants.networks.gnosis) + sideBar.verifyAddedSafesExistByIndex(2, constants.networks.ethereum) + }) + + it('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) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + wallet.connectSigner(signer) + + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('96') + sideBar.verifySafeCount(3) + sideBar.expandGroupSafes(0) + sideBar.openSortOptionsMenu() + sideBar.selectSortOption(sideBar.sortOptions.name) + sideBar.verifyAddedSafesExistByIndex(1, aSafe) + sideBar.verifyAddedSafesExistByIndex(2, bSafe) + }) + + it('Verify that a pinned safe can be sorted by name and last visited', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.pagination) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__visitedSafes, ls.visitedSafes.set1) + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.clickOnOpenSidebarBtn() + sideBar.searchSafe('15') + cy.wait(1000) + sideBar.clickOnBookmarkBtn(sideBar.sideBarSafes.safe2short) + sideBar.clearSearchInput() + sideBar.searchSafe('14') + cy.wait(1000) + sideBar.clickOnBookmarkBtn(sideBar.sideBarSafes.safe1short) + sideBar.clearSearchInput() + + sideBar.verifyPinnedSafe(sideBar.sideBarSafes.safe2short) + sideBar.verifyPinnedSafe(sideBar.sideBarSafes.safe1short) + + 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) + }) +}) diff --git a/apps/web/cypress/e2e/regression/sidebar_7.cy.js b/apps/web/cypress/e2e/regression/sidebar_7.cy.js new file mode 100644 index 000000000..1ff05d695 --- /dev/null +++ b/apps/web/cypress/e2e/regression/sidebar_7.cy.js @@ -0,0 +1,93 @@ +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 ls from '../../support/localstorage_data.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 * 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 +const signer1 = walletCredentials.OWNER_1_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_3_PRIVATE_KEY + +describe('Sidebar tests 7', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify Import/export buttons are present', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.addedSafes) + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + main.checkButtonByTextExists(sideBar.importBtnStr) + main.checkButtonByTextExists(sideBar.exportBtnStr) + }) + + // Added to prod + it('Verify the "Accounts" counter at the top is counting all safes the user owns', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], + }) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.checkAccountsCounter('2') + }) + + it('Verify that safes the user do not owns show in the watchlist after adding them', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set4) + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + wallet.connectSigner(signer1) + sideBar.openSidebar() + sideBar.verifyAddedSafesExist([sideBar.sideBarSafes.safe3short]) + }) + + it('Verify that safes that the user owns do show in the watchlist after adding them', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set4) + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + wallet.connectSigner(signer1) + sideBar.openSidebar() + sideBar.verifyAddedSafesExist([sideBar.sideBarSafes.safe3short]) + }) + + // Added to prod + it('Verify pending signature is displayed in sidebar for unsigned tx', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + wallet.connectSigner(signer) + 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) + }) + + // Added to prod + it('Verify balance exists in a tx in sidebar', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + wallet.connectSigner(signer) + owner.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer) + cy.intercept('GET', constants.safeListEndpoint, { + 11155111: [sideBar.sideBarSafesPendingActions.safe1], + }) + sideBar.openSidebar() + sideBar.verifyTxToConfirmDoesNotExist() + sideBar.checkBalanceExists() + }) +}) diff --git a/cypress/e2e/regression/sidebar_nonowner.cy.js b/apps/web/cypress/e2e/regression/sidebar_nonowner.cy.js similarity index 96% rename from cypress/e2e/regression/sidebar_nonowner.cy.js rename to apps/web/cypress/e2e/regression/sidebar_nonowner.cy.js index 629bbbea4..3104c1174 100644 --- a/cypress/e2e/regression/sidebar_nonowner.cy.js +++ b/apps/web/cypress/e2e/regression/sidebar_nonowner.cy.js @@ -21,8 +21,6 @@ describe('Sidebar non-owner tests', () => { beforeEach(() => { cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_11) cy.wait(2000) - cy.clearLocalStorage() - main.acceptCookies() main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set3) main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.addedSafes) }) diff --git a/apps/web/cypress/e2e/regression/spending_limits.cy.js b/apps/web/cypress/e2e/regression/spending_limits.cy.js new file mode 100644 index 000000000..7b8a33c71 --- /dev/null +++ b/apps/web/cypress/e2e/regression/spending_limits.cy.js @@ -0,0 +1,177 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as spendinglimit from '../pages/spending_limits.pages' +import * as navigation from '../pages/navigation.page' +import * as tx from '../pages/create_tx.pages' +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 { getMockAddress } from '../../support/utils/ethers.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const signerAddress = walletCredentials.OWNER_4_WALLET_ADDRESS + +const tokenAmount = 0.1 +const newTokenAmount = 0.001 +const spendingLimitBalance = '(0.15 ETH)' + +describe('Spending limits tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_8) + cy.get(spendinglimit.spendingLimitsSection).should('be.visible') + }) + + it('Verify resetAllowance and setAllowance actions are shown if a part of allowance was used', () => { + wallet.connectSigner(signer) + spendinglimit.clickOnNewSpendingLimitBtn() + spendinglimit.enterBeneficiaryAddress(signerAddress) + spendinglimit.enterSpendingLimitAmount(0.1) + spendinglimit.clickOnNextBtn() + spendinglimit.verifyActionCount(2) + spendinglimit.verifyActionNames([spendinglimit.actionNames.resetAllowance, spendinglimit.actionNames.setAllowance]) + }) + + it('Verify only setAllowance action is shown if allowance was not used', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_23) + wallet.connectSigner(signer) + spendinglimit.clickOnNewSpendingLimitBtn() + spendinglimit.enterBeneficiaryAddress(signerAddress) + spendinglimit.enterSpendingLimitAmount(0.1) + spendinglimit.clickOnNextBtn() + spendinglimit.verifyActionCount(0) + spendinglimit.verifyDecodedTxSummary([spendinglimit.actionNames.setAllowance]) + }) + + // Added to prod + it('Verify that the Review step shows beneficiary, amount allowed, reset time', () => { + //Assume that default reset time is set to One time + wallet.connectSigner(signer) + spendinglimit.clickOnNewSpendingLimitBtn() + spendinglimit.enterBeneficiaryAddress(getMockAddress()) + spendinglimit.enterSpendingLimitAmount(0.1) + spendinglimit.clickOnNextBtn() + spendinglimit.checkReviewData( + tokenAmount, + getMockAddress(), + spendinglimit.timePeriodOptions.oneTime.split(' ').join('-'), + ) + }) + + // Added to prod + it('Verify values and trash icons are displayed in Beneficiary table', () => { + spendinglimit.verifyBeneficiaryTable() + }) + + // Added to prod + it('Verify Spending limit option is available when selecting the corresponding token', () => { + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + tx.clickOnSendTokensBtn() + spendinglimit.verifyTxOptionExist([spendinglimit.spendingLimitTxOption]) + }) + + it('Verify spending limit option shows available amount', () => { + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + tx.clickOnSendTokensBtn() + spendinglimit.verifySpendingOptionShowsBalance([spendingLimitBalance]) + }) + + it('Verify when owner is a delegate, standard tx and spending limit tx are present', () => { + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + tx.clickOnSendTokensBtn() + spendinglimit.verifyTxOptionExist([spendinglimit.spendingLimitTxOption, spendinglimit.standardTx]) + }) + + it('Verify when spending limit is selected the nonce field is removed', () => { + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + tx.clickOnSendTokensBtn() + spendinglimit.selectSpendingLimitOption() + spendinglimit.verifyNonceState(constants.elementExistanceStates.not_exist) + }) + + it('Verify "Max" button value set to be no more than the allowed amount', () => { + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + tx.clickOnSendTokensBtn() + spendinglimit.clickOnMaxBtn() + spendinglimit.checkMaxValue() + }) + + it('Verify selecting a native token from the dropdown in new tx', () => { + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + tx.clickOnSendTokensBtn() + spendinglimit.selectToken(constants.tokenNames.sepoliaEther) + }) + + it('Verify that when replacing spending limit for the same owner, previous values are displayed in red', () => { + wallet.connectSigner(signer) + spendinglimit.clickOnNewSpendingLimitBtn() + spendinglimit.enterBeneficiaryAddress(constants.DEFAULT_OWNER_ADDRESS) + spendinglimit.enterSpendingLimitAmount(newTokenAmount) + spendinglimit.clickOnTimePeriodDropdown() + spendinglimit.selectTimePeriod(spendinglimit.timePeriodOptions.fiveMin) + tx.clickOnNextBtn() + spendinglimit.verifyOldValuesAreDisplayed() + }) + + it('Verify that when editing spending limit for owner who used some of it, relevant actions are displayed', () => { + wallet.connectSigner(signer) + spendinglimit.clickOnNewSpendingLimitBtn() + spendinglimit.enterBeneficiaryAddress(constants.SPENDING_LIMIT_ADDRESS_2) + spendinglimit.enterSpendingLimitAmount(newTokenAmount) + spendinglimit.clickOnTimePeriodDropdown() + spendinglimit.selectTimePeriod(spendinglimit.timePeriodOptions.oneTime) + tx.clickOnNextBtn() + spendinglimit.verifyActionNamesAreDisplayed([ + constants.TXActionNames.resetAllowance, + constants.TXActionNames.setAllowance, + ]) + }) + + it('Verify that when multiple assets are available, they are displayed in token dropdown', () => { + cy.wrap(null) + .then(() => main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__settings, ls.safeSettings.slimitSettings)) + .then(() => + main.isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__settings, ls.safeSettings.slimitSettings), + ) + .then(() => { + cy.reload() + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + tx.clickOnSendTokensBtn() + spendinglimit.clickOnTokenDropdown() + spendinglimit.verifyMandatoryTokensExist() + }) + }) + + it('Verify that beneficiary can be retried from address book', () => { + cy.wrap(null) + .then(() => + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress2), + ) + .then(() => + main.isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress2), + ) + .then(() => { + cy.reload() + wallet.connectSigner(signer) + spendinglimit.clickOnNewSpendingLimitBtn() + spendinglimit.enterBeneficiaryAddress(constants.DEFAULT_OWNER_ADDRESS.substring(30)) + spendinglimit.selectRecipient(constants.DEFAULT_OWNER_ADDRESS) + }) + }) + + it('Verify explorer links contain Sepolia link', () => { + tx.verifyNumberOfExternalLinks(3) + }) +}) diff --git a/cypress/e2e/regression/spending_limits_nonowner.cy.js b/apps/web/cypress/e2e/regression/spending_limits_nonowner.cy.js similarity index 94% rename from cypress/e2e/regression/spending_limits_nonowner.cy.js rename to apps/web/cypress/e2e/regression/spending_limits_nonowner.cy.js index e64bda33f..d6fe2ede2 100644 --- a/cypress/e2e/regression/spending_limits_nonowner.cy.js +++ b/apps/web/cypress/e2e/regression/spending_limits_nonowner.cy.js @@ -12,8 +12,6 @@ describe('Spending limits non-owner tests', () => { beforeEach(() => { cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_3) - cy.clearLocalStorage() - main.acceptCookies() cy.get(spendinglimit.spendingLimitsSection).should('be.visible') }) diff --git a/apps/web/cypress/e2e/regression/staking_history.cy.js b/apps/web/cypress/e2e/regression/staking_history.cy.js new file mode 100644 index 000000000..8e4a63ddb --- /dev/null +++ b/apps/web/cypress/e2e/regression/staking_history.cy.js @@ -0,0 +1,39 @@ +import * as constants from '../../support/constants.js' +import * as create_tx from '../pages/create_tx.pages.js' +import * as staking from '../pages/staking.page.js' +import * as staking_data from '../../fixtures/staking_data.json' + +const safe = 'eth:0xAD1Cf279D18f34a13c3Bf9b79F4D427D5CD9505B' +const historyData = staking_data.type.history + +describe('Staking history tests', { defaultCommandTimeout: 30000 }, () => { + it('Verify Claim tx shows amount received', () => { + cy.visit(constants.transactionUrl + safe + staking.stakingTxs.claim) + staking.checkTxHeaderData([historyData.ETH_3205184, historyData.claim]) + create_tx.verifyExpandedDetails([historyData.ETH_3205184, historyData.received]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([historyData.call_batchWithdrawCLFee, historyData.StakingContract]) + }) + + it('Verify Withdraw request shows amount of validators and Validator status', () => { + cy.visit(constants.transactionUrl + safe + staking.stakingTxs.withdrawal) + staking.checkTxHeaderData([historyData.withdrawal, historyData.validator_1]) + staking.verifyValidatorCount(1) + staking.verifyValidatorStatus(staking.validatorStatusOptions.withdrwal) + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([historyData.call_requestValidatorsExit, historyData.StakingContract]) + }) + + it('Verify Stake tx show the amount staked and proper fields', () => { + cy.visit(constants.transactionUrl + safe + staking.stakingTxs.stake) + staking.checkTxHeaderData([historyData.ETH32_2, historyData.stake]) + staking.checkDataFields(staking.dataFields.deposit, historyData.ETH_32) + staking.checkDataFields(staking.dataFields.netRewardRate, staking.getPercentageRegex()) + staking.checkDataFields(staking.dataFields.netAnnualRewards, staking.getRewardRegex()) + 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.rewards, historyData.rewardsValue) + }) +}) diff --git a/apps/web/cypress/e2e/regression/swaps.cy.js b/apps/web/cypress/e2e/regression/swaps.cy.js new file mode 100644 index 000000000..7b508a85c --- /dev/null +++ b/apps/web/cypress/e2e/regression/swaps.cy.js @@ -0,0 +1,161 @@ +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 { 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('Swaps 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 entering a blocked address in the custom recipient input blocks the form', + { defaultCommandTimeout: 30000 }, + () => { + let isCustomRecipientFound + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main + .getIframeBody(iframeSelector) + .then(($frame) => { + isCustomRecipientFound = (customRecipient) => { + const element = $frame.find(customRecipient) + return element.length > 0 + } + }) + .within(() => { + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.clickOnSettingsBtn() + swaps.enableCustomRecipient(isCustomRecipientFound(swaps.customRecipient)) + swaps.clickOnSettingsBtn() + swaps.enterRecipient(swaps.blockedAddress) + }) + cy.contains(swaps.blockedAddressStr) + }, + ) + + it('Verify enabling custom recipient adds that field to the form', { defaultCommandTimeout: 30000 }, () => { + swaps.acceptLegalDisclaimer() + cy.wait(4000) + + const isCustomRecipientFound = ($frame, customRecipient) => { + const element = $frame.find(customRecipient) + return element.length > 0 + } + + main.getIframeBody(iframeSelector).then(($frame) => { + cy.wrap($frame).within(() => { + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.clickOnSettingsBtn() + + if (isCustomRecipientFound($frame, swaps.customRecipient)) { + swaps.disableCustomRecipient(true) + cy.wait(1000) + swaps.enableCustomRecipient(!isCustomRecipientFound($frame, swaps.customRecipient)) + } else { + swaps.enableCustomRecipient(isCustomRecipientFound($frame, swaps.customRecipient)) + cy.wait(1000) + } + + swaps.clickOnSettingsBtn() + swaps.enterRecipient('1') + }) + }) + }) + + it('Verify order details are displayed in swap confirmation', { defaultCommandTimeout: 30000 }, () => { + const limitPrice = swaps.createRegex(swapOrder.DAIeqCOW, 'COW') + const widgetFee = swaps.getWidgetFee() + const orderID = swaps.getOrderID() + const slippage = swaps.getWidgetFee() + + 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) + swaps.checkSwapBtnIsVisible() + swaps.isInputGreaterZero(swaps.outputCurrencyInput).then((isGreaterThanZero) => { + cy.wrap(isGreaterThanZero).should('be.true') + }) + swaps.clickOnExceeFeeChkbox() + swaps.clickOnSwapBtn() + swaps.clickOnSwapBtn() + }) + + swaps.verifyOrderDetails(limitPrice, swapOrder.expiry2Mins, slippage, swapOrder.interactWith, orderID, widgetFee) + }) + + it( + 'Verify recipient address alert is displayed in order details if the recipient is not owner of the order', + { defaultCommandTimeout: 30000 }, + () => { + const limitPrice = swaps.createRegex(swapOrder.DAIeqCOW, 'COW') + const widgetFee = swaps.getWidgetFee() + const orderID = swaps.getOrderID() + + const isCustomRecipientFound = ($frame, customRecipient) => { + const element = $frame.find(customRecipient) + return element.length > 0 + } + + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).then(($frame) => { + cy.wrap($frame).within(() => { + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(200) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.checkSwapBtnIsVisible() + swaps.clickOnSettingsBtn() + + if (isCustomRecipientFound($frame, swaps.customRecipient)) { + swaps.disableCustomRecipient(true) + cy.wait(1000) + swaps.enableCustomRecipient(!isCustomRecipientFound($frame, swaps.customRecipient)) + } else { + swaps.enableCustomRecipient(isCustomRecipientFound($frame, swaps.customRecipient)) + cy.wait(1000) + } + + swaps.clickOnSettingsBtn() + swaps.enterRecipient(signer2) + swaps.clickOnExceeFeeChkbox() + swaps.clickOnSwapBtn() + swaps.clickOnSwapBtn() + }) + swaps.verifyRecipientAlertIsDisplayed() + }) + }, + ) +}) diff --git a/cypress/e2e/regression/swaps_history.cy.js b/apps/web/cypress/e2e/regression/swaps_history.cy.js similarity index 96% rename from cypress/e2e/regression/swaps_history.cy.js rename to apps/web/cypress/e2e/regression/swaps_history.cy.js index 95d4ffb52..9d743e254 100644 --- a/cypress/e2e/regression/swaps_history.cy.js +++ b/apps/web/cypress/e2e/regression/swaps_history.cy.js @@ -17,9 +17,7 @@ describe('Swaps history tests', () => { }) beforeEach(() => { - cy.clearLocalStorage() cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_1) - main.acceptCookies() }) it('Verify swap selling operation with one action', { defaultCommandTimeout: 30000 }, () => { diff --git a/apps/web/cypress/e2e/regression/swaps_history_2.cy.js b/apps/web/cypress/e2e/regression/swaps_history_2.cy.js new file mode 100644 index 000000000..b2930c77f --- /dev/null +++ b/apps/web/cypress/e2e/regression/swaps_history_2.cy.js @@ -0,0 +1,115 @@ +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 history tests 2', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify swap sell order with one action', { defaultCommandTimeout: 30000 }, () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_1 + swaps.swapTxs.sell1Action) + const dai = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqCOW, 'COW') + + create_tx.verifyExpandedDetails([swapsHistory.sellFull, dai, eq, swapsHistory.dai, swapsHistory.filled]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.gGpV2, swapsHistory.actionPreSignatureG]) + }) + + // Added to prod + it('Verify swap buy operation with 2 actions: approve & swap', { defaultCommandTimeout: 30000 }, () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_1 + swaps.swapTxs.buy2actions) + const eq = swaps.createRegex(swapsHistory.oneGNOFull, 'COW') + const atMost = swaps.createRegex(swapsHistory.forAtMostCow, 'COW') + + create_tx.verifyExpandedDetails([ + swapsHistory.buyOrder, + swapsHistory.buy, + eq, + atMost, + swapsHistory.cow, + swapsHistory.expired, + swapsHistory.actionApprove, + 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) + const uni = swaps.createRegex(swapsHistory.forAtLeastFullUni, 'UNI') + const eq = swaps.createRegex(swapsHistory.UNIeqCOW, 'K COW') + + create_tx.verifyExpandedDetails([ + swapsHistory.sellOrder, + swapsHistory.sell, + uni, + eq, + swapsHistory.cow, + swapsHistory.cancelled, + ]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.gGpV2, swapsHistory.actionPreSignatureG]) + }) + + it('Verify swap operation with 3 actions: wrap & approve & swap', { defaultCommandTimeout: 30000 }, () => { + const safe = '0x140663Cb76e4c4e97621395fc118912fa674150B' + cy.visit(constants.transactionUrl + safe + swaps.swapTxs.sell3Actions) + const dai = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqWETH, 'WETH') + + create_tx.verifyExpandedDetails([ + swapsHistory.sellOrder, + swapsHistory.sell, + dai, + eq, + swapsHistory.actionApproveEth, + swapsHistory.actionPreSignature, + swapsHistory.actionDepositEth, + ]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.multiSend, swapsHistory.multiSendCallOnly1_3_0]) + }) + + // Added to prod + it( + 'Verify there is decoding for a tx created by CowSwap safe-app in the history', + { defaultCommandTimeout: 30000 }, + () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_1 + swaps.swapTxs.safeAppSwapOrder) + 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.statusOk]) + main.verifyElementsExist([create_tx.altImgDai, create_tx.altImgCow], create_tx.altImgSwaps) + create_tx.verifyExpandedDetails([swapsHistory.sell10Cow, dai, eq, swapsHistory.dai, swapsHistory.filled]) + }, + ) + + it('Verify token order in sell and buy operations', { defaultCommandTimeout: 30000 }, () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_1 + swaps.swapTxs.sell1Action) + const eq = swaps.createRegex(swapsHistory.DAIeqCOW, 'COW') + swaps.checkTokenOrder(eq, swapsHistory.executionPrice) + + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_1 + swaps.swapTxs.buy2actions) + const eq2 = swaps.createRegex(swapsHistory.oneGNOFull, 'COW') + swaps.checkTokenOrder(eq2, swapsHistory.limitPrice) + }) + + it('Verify OrderID url on cowswap explorer', { defaultCommandTimeout: 30000 }, () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_1 + swaps.swapTxs.sell1Action) + swaps.verifyOrderIDUrl() + }) +}) diff --git a/apps/web/cypress/e2e/regression/swaps_tokens.cy.js b/apps/web/cypress/e2e/regression/swaps_tokens.cy.js new file mode 100644 index 000000000..08791f1bf --- /dev/null +++ b/apps/web/cypress/e2e/regression/swaps_tokens.cy.js @@ -0,0 +1,54 @@ +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 assets from '../pages/assets.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as ls from '../../support/localstorage_data.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +let iframeSelector = `iframe[src*="${constants.swapWidget}"]` + +describe('Swaps token tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_1) + }) + + // Added to prod + it( + 'Verify that clicking the swap from assets tab, autofills that token automatically in the form', + { defaultCommandTimeout: 30000 }, + () => { + wallet.connectSigner(signer) + assets.selectTokenList(assets.tokenListOptions.allTokens) + + swaps.clickOnAssetSwapBtn(0) + swaps.acceptLegalDisclaimer() + cy.wait(2000) + main.getIframeBody(iframeSelector).within(() => { + swaps.verifySelectedInputCurrancy(swaps.swapTokens.eth) + }) + }, + ) + + it('Verify swap button are displayed in assets table and dashboard', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + main.verifyElementsCount(swaps.assetsSwapBtn, 4) + cy.window().then((window) => { + window.localStorage.setItem( + constants.localStorageKeys.SAFE_v2__settings, + JSON.stringify(ls.safeSettings.slimitSettings), + ) + }) + cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_1) + main.verifyElementsCount(swaps.assetsSwapBtn, 4) + main.verifyElementsCount(swaps.dashboardSwapBtn, 1) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tokens.cy.js b/apps/web/cypress/e2e/regression/tokens.cy.js new file mode 100644 index 000000000..1d48f281e --- /dev/null +++ b/apps/web/cypress/e2e/regression/tokens.cy.js @@ -0,0 +1,184 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as assets from '../pages/assets.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as ls from '../../support/localstorage_data.js' + +const ASSET_NAME_COLUMN = 0 +const TOKEN_AMOUNT_COLUMN = 1 +const FIAT_AMOUNT_COLUMN = 2 + +let staticSafes = [] + +describe('Tokens tests', () => { + const fiatRegex = assets.fiatRegex + + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + beforeEach(() => { + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__tokenlist_onboarding, + ls.cookies.acceptedTokenListOnboarding, + ) + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_2) + }) + + // Added to prod + it('Verify that non-native tokens are present and have balance', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.verifyBalance(assets.currencyDaiCap, TOKEN_AMOUNT_COLUMN, assets.currencyDaiAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyDaiCap, + assets.currencyDaiFormat_2, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyAave, TOKEN_AMOUNT_COLUMN, assets.currencyAaveAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyAave, + assets.currentcyAaveFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyLink, TOKEN_AMOUNT_COLUMN, assets.currencyLinkAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyLink, + assets.currentcyLinkFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyTestTokenA, TOKEN_AMOUNT_COLUMN, assets.currencyTestTokenAAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyTestTokenA, + assets.currentcyTestTokenAFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyTestTokenB, TOKEN_AMOUNT_COLUMN, assets.currencyTestTokenBAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyTestTokenB, + assets.currentcyTestTokenBFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + + assets.verifyBalance(assets.currencyUSDC, TOKEN_AMOUNT_COLUMN, assets.currencyTestUSDCAlttext) + assets.verifyTokenBalanceFormat( + assets.currencyUSDC, + assets.currentcyTestUSDCFormat, + TOKEN_AMOUNT_COLUMN, + FIAT_AMOUNT_COLUMN, + fiatRegex, + ) + }) + + it('Verify that every token except the native token has a "go to blockexplorer link"', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.verifyAssetNameHasExplorerLink(assets.currencyUSDC, ASSET_NAME_COLUMN) + assets.verifyAssetNameHasExplorerLink(assets.currencyTestTokenB, ASSET_NAME_COLUMN) + assets.verifyAssetNameHasExplorerLink(assets.currencyTestTokenA, ASSET_NAME_COLUMN) + assets.verifyAssetNameHasExplorerLink(assets.currencyLink, ASSET_NAME_COLUMN) + assets.verifyAssetNameHasExplorerLink(assets.currencyAave, ASSET_NAME_COLUMN) + assets.verifyAssetNameHasExplorerLink(assets.currencyDaiCap, ASSET_NAME_COLUMN) + assets.verifyAssetExplorerLinkNotAvailable(constants.tokenNames.sepoliaEther, ASSET_NAME_COLUMN) + }) + + it('Verify the default Fiat currency and the effects after changing it', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.verifyFirstRowDoesNotContainCurrency(assets.currencyEUR, FIAT_AMOUNT_COLUMN) + assets.verifyFirstRowContainsCurrency(assets.currency$, FIAT_AMOUNT_COLUMN) + assets.clickOnCurrencyDropdown() + assets.selectCurrency(assets.currencyOptionEUR) + assets.verifyFirstRowDoesNotContainCurrency(assets.currency$, FIAT_AMOUNT_COLUMN) + assets.verifyFirstRowContainsCurrency(assets.currencyEUR, FIAT_AMOUNT_COLUMN) + }) + + it('Verify that checking the checkboxes increases the token selected counter', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.openHideTokenMenu() + assets.clickOnTokenCheckbox(assets.currencyLink) + assets.checkTokenCounter(1) + }) + + it('Verify that selecting tokens and saving hides them from the table', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.openHideTokenMenu() + assets.clickOnTokenCheckbox(assets.currencyLink) + assets.saveHiddenTokenSelection() + main.verifyValuesDoNotExist(assets.tokenListTable, [assets.currencyLink]) + }) + + it('Verify that Cancel closes the menu and does not change the table status', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.openHideTokenMenu() + assets.clickOnTokenCheckbox(assets.currencyLink) + assets.clickOnTokenCheckbox(assets.currencyAave) + assets.saveHiddenTokenSelection() + main.verifyValuesDoNotExist(assets.tokenListTable, [assets.currencyLink, assets.currencyAave]) + assets.openHideTokenMenu() + assets.clickOnTokenCheckbox(assets.currencyLink) + assets.clickOnTokenCheckbox(assets.currencyAave) + assets.cancelSaveHiddenTokenSelection() + main.verifyValuesDoNotExist(assets.tokenListTable, [assets.currencyLink, assets.currencyAave]) + }) + + it('Verify that Deselect All unchecks all tokens from the list', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.openHideTokenMenu() + assets.clickOnTokenCheckbox(assets.currencyLink) + assets.clickOnTokenCheckbox(assets.currencyAave) + assets.deselecAlltHiddenTokenSelection() + assets.verifyEachRowHasCheckbox(constants.checkboxStates.unchecked) + }) + + it('Verify the Hidden tokens counter works for spam tokens', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.openHideTokenMenu() + assets.clickOnTokenCheckbox(assets.currencyLink) + assets.saveHiddenTokenSelection() + assets.checkHiddenTokenBtnCounter(1) + }) + + it('Verify the Hidden tokens counter works for native tokens', () => { + assets.openHideTokenMenu() + assets.clickOnTokenCheckbox(constants.tokenNames.sepoliaEther) + assets.saveHiddenTokenSelection() + assets.checkHiddenTokenBtnCounter(1) + }) + + it('Verify you can hide tokens from the eye icon in the table rows', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.hideAsset(assets.currencyLink) + }) + + it('Verify the sorting of "Assets" and "Balance" in the table', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.verifyTableRows(7) + assets.clickOnTokenNameSortBtn() + assets.verifyTokenNamesOrder() + assets.clickOnTokenNameSortBtn() + assets.verifyTokenNamesOrder('descending') + assets.clickOnTokenBalanceSortBtn() + assets.verifyTokenBalanceOrder() + assets.clickOnTokenBalanceSortBtn() + assets.verifyTokenBalanceOrder('descending') + }) + + // Added to prod + it('Verify that when connected user is not owner, Send button is disabled', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_3) + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.showSendBtn(0) + assets.VerifySendButtonIsDisabled() + }) +}) diff --git a/apps/web/cypress/e2e/regression/twaps.cy.js b/apps/web/cypress/e2e/regression/twaps.cy.js new file mode 100644 index 000000000..5b283a851 --- /dev/null +++ b/apps/web/cypress/e2e/regression/twaps.cy.js @@ -0,0 +1,79 @@ +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('Twaps tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_27) + wallet.connectSigner(signer) + iframeSelector = `iframe[src*="${constants.swapWidget}"]` + }) + + 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.daiTest, balance: '0' }, + { name: swaps.swapTokenNames.gnoTest, balance: '0' }, + { name: swaps.swapTokenNames.uni, balance: '0' }, + { name: swaps.swapTokenNames.usdcTest, balance: '0' }, + { name: swaps.swapTokenNames.usdt, balance: '0' }, + { name: swaps.swapTokenNames.weth, balance: '0' }, + ] + + swaps.acceptLegalDisclaimer() + cy.wait(4000) + + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + }) + swaps.unlockTwapOrders(iframeSelector) + main.getIframeBody(iframeSelector).within(() => { + swaps.clickOnTokenSelctor('input') + swaps.checkTokenList(tokens) + }) + }) + + it('Verify "Balances" tag and value is present for selected token', () => { + const tokenValue = swaps.getTokenValue() + + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + }) + swaps.unlockTwapOrders(iframeSelector) + main.getIframeBody(iframeSelector).within(() => { + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(500) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.checkTokenBalanceAndValue('input', '750 COW', tokenValue) + }) + }) + + it('Verify that the "Max" button sets the value as the max balance', () => { + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + }) + swaps.unlockTwapOrders(iframeSelector) + main.getIframeBody(iframeSelector).within(() => { + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.clickOnMaxBtn() + swaps.checkInputValue('input', '750') + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/twaps_2.cy.js b/apps/web/cypress/e2e/regression/twaps_2.cy.js new file mode 100644 index 000000000..c37923b04 --- /dev/null +++ b/apps/web/cypress/e2e/regression/twaps_2.cy.js @@ -0,0 +1,94 @@ +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('Twaps 2 tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_27) + main.waitForHistoryCallToComplete() + wallet.connectSigner(signer) + iframeSelector = `iframe[src*="${constants.swapWidget}"]` + }) + + it( + 'Verify "Insufficient balance" message appears when the entered token amount exceeds "Max" balance', + { defaultCommandTimeout: 30000 }, + () => { + wallet.connectSigner(signer) + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + }) + swaps.unlockTwapOrders(iframeSelector) + main.getIframeBody(iframeSelector).within(() => { + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(2000) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.checkInsufficientBalanceMessageDisplayed(swaps.swapTokens.cow) + }) + }, + ) + + it( + 'Verify "Sell amount too low" if the amount of tokens is worth less than 200 USD', + { defaultCommandTimeout: 30000 }, + () => { + wallet.connectSigner(signer) + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + }) + swaps.unlockTwapOrders(iframeSelector) + main.getIframeBody(iframeSelector).within(() => { + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(100) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.checkSmallSellAmountMessageDisplayed() + }) + }, + ) + + it( + 'Verify entering a blocked address in the custom recipient input blocks the form', + { defaultCommandTimeout: 30000 }, + () => { + let isCustomRecipientFound + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main + .getIframeBody(iframeSelector) + .then(($frame) => { + isCustomRecipientFound = (customRecipient) => { + const element = $frame.find(customRecipient) + return element.length > 0 + } + }) + .within(() => { + swaps.switchToTwap() + }) + swaps.unlockTwapOrders(iframeSelector) + main.getIframeBody(iframeSelector).within(() => { + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.clickOnSettingsBtnTwaps() + swaps.enableTwapCustomRecipient() + swaps.clickOnSettingsBtnTwaps() + swaps.enterRecipient(swaps.blockedAddress) + }) + 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 new file mode 100644 index 000000000..fc8445dca --- /dev/null +++ b/apps/web/cypress/e2e/regression/twaps_history.cy.js @@ -0,0 +1,105 @@ +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 swaps_data from '../../fixtures/swaps_data.json' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +let staticSafes = [] + +let iframeSelector + +const swapsHistory = swaps_data.type.history +const swapOrder = swaps_data.type.orderDetails + +describe('Twaps history tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify order details', { defaultCommandTimeout: 60000 }, () => { + const limitPrice = swaps.createRegex(swapOrder.DAIeqCOW, 'COW') + const widgetFee = swaps.getWidgetFee() + const slippage = swaps.getWidgetFee() + + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_27) + main.waitForHistoryCallToComplete() + wallet.connectSigner(signer) + iframeSelector = `iframe[src*="${constants.swapWidget}"]` + swaps.acceptLegalDisclaimer() + main.getIframeBody(iframeSelector).within(() => { + swaps.switchToTwap() + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(500) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.verifyReviewOrderBtnIsVisible() + swaps.getTwapInitialData().then((formData) => { + cy.wrap(formData).as('twapFormData') + swaps.clickOnReviewOrderBtn() + swaps.placeTwapOrder() + }) + }) + + 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) + }) + }) + + it('Verify partially filled sell order', () => { + const tx = + 'sep:0x8f4A19C85b39032A37f7a6dCc65234f966F72551&id=multisig_0x8f4A19C85b39032A37f7a6dCc65234f966F72551_0x2fdf5e5d94306de5f7285fd74ca014067b090338b3ff15e3f66d6c02ef81e4a4' + cy.visit(constants.transactionUrl + tx) + const weth = swaps.createRegex(swapsHistory.forAtLeastFullWETH, 'WETH') + const eq = swaps.createRegex(swapsHistory.WETHeqDAI, 'DAI') + const sellAmount = swaps.getTokenPrice('DAI') + const buyAmount = swaps.getTokenPrice('WETH') + const tokenSoldPrice = swaps.getTokenPrice('DAI') + + create_tx.verifyExpandedDetails([swapsHistory.sell, weth, eq, swapsHistory.dai, swapsHistory.partiallyFilled]) + swaps.checkNumberOfParts(2) + swaps.checkSellAmount(sellAmount) + swaps.checkBuyAmount(buyAmount) + swaps.checkPercentageFilled(50, tokenSoldPrice) + swaps.checkPartDuration('30 minutes') + + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.createWithContext, swapsHistory.composableCoW]) + }) + + it('Verify that an order has the received and sent txs', () => { + const sentValue = '-250 COW' + const receivedValue = '303.16951 DAI' + + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_27) + create_tx.toggleUntrustedTxs() + swaps.checkTwapSettlement(0, sentValue, receivedValue) + }) + + it('Verify fully filled sell order', () => { + const tx = + 'sep:0x8f4A19C85b39032A37f7a6dCc65234f966F72551&id=multisig_0x8f4A19C85b39032A37f7a6dCc65234f966F72551_0xc8a9399afbba45e82a0645770db38386cbe10bec77dd8b6395f7d24e19a45c9a' + cy.visit(constants.transactionUrl + tx) + const weth = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqWETH, 'WETH') + const sellAmount = swaps.getTokenPrice('WETH') + const buyAmount = swaps.getTokenPrice('DAI') + const tokenSoldPrice = swaps.getTokenPrice('WETH') + + create_tx.verifyExpandedDetails([swapsHistory.sell, weth, eq, swapsHistory.dai, swapsHistory.filled]) + swaps.checkNumberOfParts(2) + swaps.checkSellAmount(sellAmount) + swaps.checkBuyAmount(buyAmount) + swaps.checkPercentageFilled(100, tokenSoldPrice) + swaps.checkPartDuration('30 minutes') + + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.createWithContext, swapsHistory.composableCoW]) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_decoding.cy.js b/apps/web/cypress/e2e/regression/tx_decoding.cy.js new file mode 100644 index 000000000..9bf12f199 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_decoding.cy.js @@ -0,0 +1,17 @@ +import * as main from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' +import * as constants from '../../support/constants.js' + +const safe = 'sep:0x2a73e61bd15b25B6958b4DA3bfc759ca4db249b9' +const decodedTx = + '&id=multisig_0x2a73e61bd15b25B6958b4DA3bfc759ca4db249b9_0xa3e73a212d7025c08048a05dcd829a88d1bf8a7c0d9eaf453b3b6039ad6156f3' + +//TODO: Check file error +describe('Tx decoding tests', () => { + it.skip('Check visual tx', () => { + cy.visit(constants.transactionUrl + safe + decodedTx) + createTx.clickOnExpandAllActionsBtn() + cy.wait(1000) + cy.compareSnapshot('tx_decoding', { errorThreshold: 0, failSilently: false }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_history.cy.js b/apps/web/cypress/e2e/regression/tx_history.cy.js new file mode 100644 index 000000000..20b28c556 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_history.cy.js @@ -0,0 +1,160 @@ +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' + +let staticSafes = [] + +const typeCreateAccount = data.type.accountCreation +const typeReceive = data.type.receive +const typeSend = data.type.send +const typeSpendingLimits = data.type.spendingLimits +const typeDeleteAllowance = data.type.deleteSpendingLimit +const typeSideActions = data.type.sideActions +const typeGeneral = data.type.general + +describe('Tx history tests 1', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.intercept( + 'GET', + `**${constants.stagingCGWChains}${constants.networkKeys.sepolia}/${ + constants.stagingCGWSafes + }${staticSafes.SEP_STATIC_SAFE_7.substring(4)}/transactions/history**`, + (req) => { + req.url = `https://safe-client.staging.5afe.dev/v1/chains/11155111/safes/0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb/transactions/history?timezone=Europe/Berlin&trusted=false&cursor=limit=100&offset=1` + req.continue() + }, + ).as('allTransactions') + + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + cy.wait('@allTransactions') + }) + + // Added to prod + // Account creation + it('Verify summary for account creation', () => { + createTx.verifySummaryByName( + typeCreateAccount.title, + null, + [typeCreateAccount.actionsSummary, typeGeneral.statusOk], + typeCreateAccount.altTmage, + null, + ) + }) + + // Added to prod + it('Verify exapanded details for account creation', () => { + createTx.clickOnTransactionItemByName(typeCreateAccount.title) + createTx.verifyExpandedDetails([ + typeCreateAccount.creator.actionTitle, + typeCreateAccount.creator.address, + typeCreateAccount.factory.actionTitle, + typeCreateAccount.factory.name, + typeCreateAccount.factory.address, + typeCreateAccount.masterCopy.actionTitle, + typeCreateAccount.masterCopy.name, + typeCreateAccount.masterCopy.address, + typeCreateAccount.transactionHash, + ]) + }) + + it('Verify external links exist for account creation', () => { + createTx.clickOnTransactionItemByName(typeCreateAccount.title) + createTx.verifyNumberOfExternalLinks(4) + }) + + // Added to prod + // Token send + it('Verify exapanded details for token send', () => { + createTx.clickOnTransactionItemByName(typeSend.title, typeSend.summaryTxInfo) + createTx.verifyExpandedDetails([typeSend.sentTo, typeSend.recipientAddress, typeSend.transactionHash]) + createTx.verifyActionListExists([ + typeSideActions.created, + typeSideActions.confirmations, + typeSideActions.executedBy, + ]) + }) + + // Added to prod + // Spending limits + it('Verify summary for setting spend limits', () => { + // name, token, data, alt, altToken + createTx.verifySummaryByName( + typeSpendingLimits.title, + typeSpendingLimits.summaryTxInfo, + [typeGeneral.statusOk], + typeSpendingLimits.altImage, + ) + }) + + // Added to prod + 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, + ], + createTx.delegateCallWarning, + ) + }) + + // Added to prod + it('Verify that 3 actions exist in initial spending limits setup', () => { + createTx.clickOnTransactionItemByName(typeSpendingLimits.title, typeSpendingLimits.summaryTxInfo) + createTx.verifyActions([ + typeSpendingLimits.enableModule.title, + typeSpendingLimits.addDelegate.title, + typeSpendingLimits.setAllowance.title, + ]) + }) + + it('Verify that all 3 actions can be expanded and collapsed in initial spending limits setup', () => { + createTx.clickOnTransactionItemByName(typeSpendingLimits.title, typeSpendingLimits.summaryTxInfo) + createTx.expandAllActions([ + typeSpendingLimits.enableModule.title, + typeSpendingLimits.addDelegate.title, + typeSpendingLimits.setAllowance.title, + ]) + createTx.collapseAllActions([ + typeSpendingLimits.enableModule.moduleAddressTitle, + typeSpendingLimits.addDelegate.delegateAddressTitle, + typeSpendingLimits.setAllowance.delegateAddressTitle, + ]) + }) + + it('Verify that addDelegate action can be expanded and collapsed in spending limits', () => { + createTx.clickOnTransactionItemByName(typeSpendingLimits.title, typeSpendingLimits.summaryTxInfo) + createTx.clickOnExpandableAction(typeSpendingLimits.addDelegate.title) + createTx.verifyActions([typeSpendingLimits.addDelegate.delegateAddressTitle]) + createTx.collapseAllActions([typeSpendingLimits.addDelegate.delegateAddressTitle]) + }) + + // Spending limit deletion + it('Verify exapanded details for allowance deletion', () => { + createTx.clickOnTransactionItemByName(typeDeleteAllowance.title, typeDeleteAllowance.summaryTxInfo) + createTx.verifyExpandedDetails([ + typeDeleteAllowance.description, + typeDeleteAllowance.beneficiary, + typeDeleteAllowance.beneficiaryAddress, + typeDeleteAllowance.transactionHash, + typeDeleteAllowance.safeTxHash, + typeDeleteAllowance.token, + typeDeleteAllowance.tokenName, + ]) + }) + + // 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.collapseAdvancedDetails([typeDeleteAllowance.baseGas]) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_history_2.cy.js b/apps/web/cypress/e2e/regression/tx_history_2.cy.js new file mode 100644 index 000000000..e132c6702 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_history_2.cy.js @@ -0,0 +1,174 @@ +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' + +let staticSafes = [] + +const typeOnchainRejection = data.type.onchainRejection +const typeBatch = data.type.batchNativeTransfer +const typeAddOwner = data.type.addOwner +const typeChangeOwner = data.type.swapOwner +const typeRemoveOwner = data.type.removeOwner +const typeDisableOwner = data.type.disableModule +const typeChangeThreshold = data.type.changeThreshold +const typeSideActions = data.type.sideActions +const typeGeneral = data.type.general +const typeUntrustedToken = data.type.untrustedReceivedToken + +describe('Tx history tests 2', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.intercept( + 'GET', + `**${constants.stagingCGWChains}${constants.networkKeys.sepolia}/${ + constants.stagingCGWSafes + }${staticSafes.SEP_STATIC_SAFE_7.substring(4)}/transactions/history**`, + (req) => { + req.url = `https://safe-client.staging.5afe.dev/v1/chains/11155111/safes/0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb/transactions/history?timezone=Europe/Berlin&trusted=false&cursor=limit=100&offset=1` + req.continue() + }, + ).as('allTransactions') + + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + }) + + it('Verify number of transactions is correct', () => { + createTx.verifyNumberOfTransactions(20) + }) + + // Added to prod + // On-chain rejection + it('Verify exapanded details for on-chain rejection', () => { + createTx.clickOnTransactionItemByName(typeOnchainRejection.title) + createTx.verifyExpandedDetails([ + typeOnchainRejection.description, + typeOnchainRejection.transactionHash, + typeOnchainRejection.safeTxHash, + ]) + createTx.verifyActionListExists([ + typeSideActions.rejectionCreated, + typeSideActions.confirmations, + typeSideActions.executedBy, + ]) + }) + + // Added to prod + // 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.verifyActions([typeBatch.nativeTransfer.title]) + }) + + // Added to prod + // Add owner + it('Verify summary for adding owner', () => { + createTx.verifySummaryByName(typeAddOwner.title, null, [typeGeneral.statusOk], typeAddOwner.altImage) + }) + + it('Verify exapanded details for adding owner', () => { + createTx.clickOnTransactionItemByName(typeAddOwner.title) + createTx.verifyExpandedDetails( + [ + typeAddOwner.description, + typeAddOwner.requiredConfirmationsTitle, + typeAddOwner.ownerAddress, + typeAddOwner.transactionHash, + typeAddOwner.safeTxHash, + ], + createTx.policyChangeWarning, + ) + }) + + // Added to prod + // Change owner + it('Verify summary for changing owner', () => { + createTx.verifySummaryByName(typeChangeOwner.title, null, [typeGeneral.statusOk], typeChangeOwner.altImage) + }) + + // Added to prod + it('Verify exapanded details for changing owner', () => { + createTx.clickOnTransactionItemByName(typeChangeOwner.title) + createTx.verifyExpandedDetails([ + typeChangeOwner.description, + typeChangeOwner.newOwner.actionTitile, + typeChangeOwner.newOwner.ownerAddress, + typeChangeOwner.oldOwner.actionTitile, + typeChangeOwner.oldOwner.ownerAddress, + + typeChangeOwner.transactionHash, + typeChangeOwner.safeTxHash, + ]) + }) + + // Added to prod + // Remove owner + it('Verify summary for removing owner', () => { + createTx.verifySummaryByName(typeRemoveOwner.title, null, [typeGeneral.statusOk], typeRemoveOwner.altImage) + }) + + it('Verify exapanded details for removing owner', () => { + createTx.clickOnTransactionItemByName(typeRemoveOwner.title) + createTx.verifyExpandedDetails( + [ + typeRemoveOwner.description, + typeRemoveOwner.requiredConfirmationsTitle, + typeRemoveOwner.ownerAddress, + typeRemoveOwner.transactionHash, + typeRemoveOwner.safeTxHash, + ], + createTx.policyChangeWarning, + ) + createTx.checkRequiredThreshold(1) + }) + + // Added to prod + // Disbale module + it('Verify summary for disable module', () => { + createTx.verifySummaryByName(typeDisableOwner.title, null, [typeGeneral.statusOk], typeDisableOwner.altImage) + }) + + it('Verify exapanded details for disable module', () => { + createTx.clickOnTransactionItemByName(typeDisableOwner.title) + createTx.verifyExpandedDetails([ + typeDisableOwner.description, + typeDisableOwner.address, + typeDisableOwner.transactionHash, + typeDisableOwner.safeTxHash, + ]) + }) + + // Added to prod + // Change threshold + it('Verify summary for changing threshold', () => { + createTx.verifySummaryByName(typeChangeThreshold.title, null, [typeGeneral.statusOk], typeChangeThreshold.altImage) + }) + + // Added to prod + it('Verify exapanded details for changing threshold', () => { + createTx.clickOnTransactionItemByName(typeChangeThreshold.title) + createTx.verifyExpandedDetails( + [ + typeChangeThreshold.requiredConfirmationsTitle, + typeChangeThreshold.transactionHash, + typeChangeThreshold.safeTxHash, + ], + createTx.policyChangeWarning, + ) + createTx.checkRequiredThreshold(2) + }) + + // Added to prod + it('Verify that sender address of untrusted token will not be copied until agreed in warning popup', () => { + createTx.clickOnTransactionItemByName(typeUntrustedToken.summaryTitle, typeUntrustedToken.summaryTxInfo) + createTx.verifyAddressNotCopied(0, typeUntrustedToken.senderAddress) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_history_3.cy.js b/apps/web/cypress/e2e/regression/tx_history_3.cy.js new file mode 100644 index 000000000..933dd1c83 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_history_3.cy.js @@ -0,0 +1,68 @@ +import * as constants from '../../support/constants.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 typeReceive = data.type.receive +const typeGeneral = data.type.general + +describe('Incoming tx history tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.intercept( + 'GET', + `**${constants.stagingCGWChains}${constants.networkKeys.sepolia}/${ + constants.stagingCGWSafes + }${staticSafes.SEP_STATIC_SAFE_7.substring(4)}/transactions/history**`, + { fixture: 'txhistory_incoming_data.json' }, + ).as('txHistory') + + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + cy.wait('@txHistory') + }) + + it('Verify Incoming ERC20 with logo in the history', () => { + createTx.verifySummaryByName( + typeReceive.summaryTxInfoDAI, + null, + [typeReceive.summaryTitle, typeGeneral.statusOk], + typeReceive.altImage, + typeReceive.altImageDAI, + ) + }) + + it('Verify Incoming ERC20 without logo in the history', () => { + createTx.verifySummaryByName( + typeReceive.summaryTxInfoETH35, + null, + [typeReceive.summaryTitle, typeGeneral.statusOk], + typeReceive.altImage, + typeReceive.altTokenETH35, + ) + }) + + it('Verify Incoming native token in the history', () => { + createTx.verifySummaryByName( + typeReceive.summaryTxInfoETH, + null, + [typeReceive.summaryTitle, typeGeneral.statusOk], + typeReceive.altImage, + typeReceive.altToken, + ) + }) + + it('Verify Incoming NFT in the history', () => { + createTx.verifySummaryByName( + typeReceive.summaryTxInfoNFT, + null, + [typeReceive.summaryTitle, typeGeneral.statusOk], + typeReceive.altImage, + typeReceive.altTokenNFT, + ) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_history_4.cy.js b/apps/web/cypress/e2e/regression/tx_history_4.cy.js new file mode 100644 index 000000000..39ba1e23e --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_history_4.cy.js @@ -0,0 +1,84 @@ +import * as constants from '../../support/constants.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' +import * as address_book from '../pages/address_book.page.js' + +let staticSafes = [] + +const typeReceive = data.type.receive +const typeGeneral = data.type.general + +const safe = 'eth:0x8675B754342754A30A2AeF474D114d8460bca19b' +const dai = + '&id=transfer_0x8675B754342754A30A2AeF474D114d8460bca19b_e715646c00d8c513b16de213dbdcfea16f58aa1294306fdd5866a4d1fab643e4794' +const nft = + '&id=transfer_0x8675B754342754A30A2AeF474D114d8460bca19b_e3873b1a1310fd4acd00249456b9700ea7fbe1e61261c3efd08a288abf8756d0b138' +const eth = + '&id=transfer_0x8675B754342754A30A2AeF474D114d8460bca19b_idc6b8280a40b5979908bc7a116b38ac6b7ae22feea09fbc1dc1373421ff4f250' + +describe('Incoming tx history details tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify Incoming details ERC20', () => { + cy.visit(constants.transactionUrl + safe + dai) + createTx.verifySummaryByName( + typeReceive.summaryTxInfoDAI, + null, + [typeReceive.summaryTitle, typeGeneral.statusOk], + typeReceive.altImage, + typeReceive.altImageDAI, + ) + createTx.verifyExpandedDetails([ + typeReceive.GPv2Settlement, + typeReceive.GPv2SettlementAddress, + typeReceive.txHashDAI, + typeReceive.executionDateDAI, + ]) + createTx.verifyNumberOfExternalLinks(2) + }) + + it('Verify Incoming details ERC721', () => { + cy.visit(constants.transactionUrl + safe + nft) + createTx.verifySummaryByName( + typeReceive.summaryTxInfoNFT, + null, + [typeReceive.summaryTitle, typeGeneral.statusOk], + typeReceive.altImage, + typeReceive.altTokenNFT, + ) + createTx.verifyExpandedDetails([ + typeReceive.Proxy, + typeReceive.ProxyAddress, + typeReceive.nftHash, + typeReceive.executionDateNFT, + ]) + createTx.verifyNumberOfExternalLinks(2) + }) + + it('Verify Incoming details Native token', () => { + cy.visit(constants.transactionUrl + safe + eth) + createTx.verifySummaryByName( + typeReceive.summaryTxInfoETH_2, + null, + [typeReceive.summaryTitle, typeGeneral.statusOk], + typeReceive.altImage, + typeReceive.altToken, + ) + createTx.verifyExpandedDetails([typeReceive.senderAddressEth, typeReceive.txHashEth, typeReceive.executionDateEth]) + createTx.verifyNumberOfExternalLinks(2) + }) + + it('Verify add to the address book for the sender in the incoming tx', () => { + const senderName = 'Sender100' + cy.visit(constants.transactionUrl + safe + eth) + address_book.clickOnMoreActionsBtn() + address_book.clickOnAddToAddressBookBtn() + address_book.typeInName(senderName) + address_book.clickOnSaveEntryBtn() + cy.visit(constants.addressBookUrl + safe) + cy.contains(senderName) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_history_5.cy.js b/apps/web/cypress/e2e/regression/tx_history_5.cy.js new file mode 100644 index 000000000..657d5cb1b --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_history_5.cy.js @@ -0,0 +1,35 @@ +import * as constants from '../../support/constants.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' +import * as main from '../pages/main.page.js' + +let staticSafes = [] + +const typeSend = data.type.send +const typeGeneral = data.type.general +const typeUntrustedToken = data.type.untrustedReceivedToken + +const safe = 'sep:0x8f4A19C85b39032A37f7a6dCc65234f966F72551' +const txbuilder = + '&id=multisig_0x8f4A19C85b39032A37f7a6dCc65234f966F72551_0x97d4c1b3149853c0d8ca71bd700faae628f0a833bdb4bd9c6b14c171117703d4' + +describe('Safe app tx history tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify tx builder has icon and app name', () => { + cy.visit(constants.transactionUrl + safe + txbuilder) + createTx.verifySummaryByName(typeSend.txBuilderTitle, null, [typeGeneral.statusOk], typeSend.txBuilderAltImage) + main.verifyValuesExist(createTx.transactionItem, [typeSend.txBuilderTitle]) + }) + + it('Verify that copying sender address of untrusted token shows warning popup', () => { + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + createTx.toggleUntrustedTxs() + createTx.clickOnTransactionItemByName(typeUntrustedToken.summaryTitle, typeUntrustedToken.summaryTxInfo) + createTx.clickOnCopyBtn(0) + createTx.verifyWarningModalVisible() + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_queue.cy.js b/apps/web/cypress/e2e/regression/tx_queue.cy.js new file mode 100644 index 000000000..b93a2e484 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_queue.cy.js @@ -0,0 +1,95 @@ +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 +const swapsQueue = swaps_data.type.queue +const orderDetails = swaps_data.type.orderDetails + +describe('Transaction queue tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify sell Limit order in queue', () => { + 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.verifyTxHeaderDetails([swapsQueue.oneOfTwo, swapsHistory.limitorder_title]) + create_tx.verifyExpandedDetails([ + swapsHistory.filled, + swapsHistory.status, + swapsHistory.oderId, + swapsHistory.limitPrice, + swapsHistory.sellOrder, + swapsHistory.sell, + dai, + eq, + swapsHistory.executionNeeded, + ]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyExpandedDetails( + [swapsHistory.multiSend, swapsHistory.multiSend, swapsHistory.multiSendCallOnly1_4_1], + create_tx.delegateCallWarning, + ) + main.verifyElementsExist([create_tx.altImgDai, create_tx.altImgCow]) + }) + + it('Verify sell Swap order in queue', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellSwapQLimitOrder) + const dai = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqCOW, 'COW') + + create_tx.verifyTxHeaderDetails([swapsQueue.oneOfTwo, swapsHistory.title]) + create_tx.verifyExpandedDetails([ + swapsHistory.status, + swapsHistory.oderId, + swapsHistory.limitPrice, + swapsHistory.sellOrder, + swapsHistory.sell, + swapsHistory.expired, + + dai, + eq, + ]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyExpandedDetails( + [swapsHistory.multiSend, swapsHistory.multiSend, swapsHistory.multiSendCallOnly1_4_1], + create_tx.delegateCallWarning, + ) + main.verifyElementsExist([create_tx.altImgDai, create_tx.altImgCow]) + }) + + it('Verify sell TWAP order in 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.verifyTxHeaderDetails([swapsQueue.oneOfTwo, swapsHistory.twaporder_title]) + create_tx.verifyExpandedDetails([ + swapsHistory.filled, + swapsHistory.limitPrice, + swapsHistory.sellOrder, + swapsHistory.sell, + swapsHistory.executionNeeded, + orderDetails.expiry12Months, + dai, + eq, + ]) + swaps.checkNumberOfParts(2) + swaps.checkSellAmount(sellAmount) + swaps.checkBuyAmount(buyAmount) + swaps.checkPercentageFilled(0, tokenSoldPrice) + swaps.checkPartDuration('182 days') + main.verifyElementsExist([create_tx.altImgDai, create_tx.altImgCow]) + }) +}) diff --git a/cypress/e2e/safe-apps/apps_list.cy.js b/apps/web/cypress/e2e/safe-apps/apps_list.cy.js similarity index 77% rename from cypress/e2e/safe-apps/apps_list.cy.js rename to apps/web/cypress/e2e/safe-apps/apps_list.cy.js index 7d3e4750e..f1dc6d05d 100644 --- a/cypress/e2e/safe-apps/apps_list.cy.js +++ b/apps/web/cypress/e2e/safe-apps/apps_list.cy.js @@ -2,6 +2,7 @@ import * as constants from '../../support/constants' import * as main from '../pages/main.page' import * as safeapps from '../pages/safeapps.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as ls from '../../support/localstorage_data.js' const myCustomAppTitle = 'Cypress Test App' const myCustomAppDescrAdded = 'Cypress Test App Description' @@ -14,11 +15,9 @@ describe('Safe Apps list tests', () => { }) beforeEach(() => { - cy.clearLocalStorage() cy.visit(`${constants.appsUrl}?safe=${staticSafes.SEP_STATIC_SAFE_1}`, { failOnStatusCode: false, }) - main.acceptCookies() }) it('Verify app list can be filtered by app name', () => { @@ -41,13 +40,13 @@ describe('Safe Apps list tests', () => { it('Verify apps can be pinned', () => { safeapps.clearSearchAppInput() - safeapps.pinApp(safeapps.transactionBuilderStr) + safeapps.pinApp(0, safeapps.transactionBuilderStr) safeapps.verifyPinnedAppCount(1) }) it('Verify apps can be unpinned', () => { - safeapps.pinApp(safeapps.transactionBuilderStr) - safeapps.pinApp(safeapps.transactionBuilderStr, false) + safeapps.pinApp(0, safeapps.transactionBuilderStr) + safeapps.pinApp(0, safeapps.transactionBuilderStr, false) safeapps.verifyPinnedAppCount(0) }) @@ -77,4 +76,15 @@ describe('Safe Apps list tests', () => { safeapps.verifyCustomAppCount(1) safeapps.verifyAppDescription(myCustomAppDescrAdded) }) + + it('Verify the featured apps list', () => { + safeapps.verifyAppInFeaturedList(safeapps.transactionBuilderStr) + safeapps.verifyAppInFeaturedList(safeapps.cowswapStr) + }) + + it('Verify that pinned app can be in pinned section and in featured at the same time', () => { + safeapps.pinApp(0, safeapps.transactionBuilderStr) + safeapps.verifyAppInFeaturedList(safeapps.transactionBuilderStr) + safeapps.verifyAppInPinnedList(safeapps.transactionBuilderStr) + }) }) diff --git a/cypress/e2e/safe-apps/browser_permissions.cy.js b/apps/web/cypress/e2e/safe-apps/browser_permissions.cy.js similarity index 95% rename from cypress/e2e/safe-apps/browser_permissions.cy.js rename to apps/web/cypress/e2e/safe-apps/browser_permissions.cy.js index fc08642fd..f42f30af6 100644 --- a/cypress/e2e/safe-apps/browser_permissions.cy.js +++ b/apps/web/cypress/e2e/safe-apps/browser_permissions.cy.js @@ -4,7 +4,6 @@ import * as safeapps from '../pages/safeapps.pages' describe('Browser permissions tests', () => { beforeEach(() => { - cy.clearLocalStorage() cy.fixture('safe-app').then((html) => { cy.intercept('GET', `${constants.testAppUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { @@ -15,7 +14,6 @@ describe('Browser permissions tests', () => { }) }) cy.visitSafeApp(`${constants.testAppUrl}/app`) - main.acceptCookies() }) // @TODO: unknown apps don't have permissions diff --git a/cypress/e2e/safe-apps/constants.js b/apps/web/cypress/e2e/safe-apps/constants.js similarity index 100% rename from cypress/e2e/safe-apps/constants.js rename to apps/web/cypress/e2e/safe-apps/constants.js diff --git a/cypress/e2e/safe-apps/drain_account.spec.cy.js b/apps/web/cypress/e2e/safe-apps/drain_account.spec.cy.js similarity index 79% rename from cypress/e2e/safe-apps/drain_account.spec.cy.js rename to apps/web/cypress/e2e/safe-apps/drain_account.spec.cy.js index 032e75157..1698ae00d 100644 --- a/cypress/e2e/safe-apps/drain_account.spec.cy.js +++ b/apps/web/cypress/e2e/safe-apps/drain_account.spec.cy.js @@ -1,14 +1,18 @@ import 'cypress-file-upload' import * as constants from '../../support/constants' -import * as main from '../pages/main.page' import * as safeapps from '../pages/safeapps.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 { getMockAddress } from '../../support/utils/ethers.js' let safeAppSafes = [] let iframeSelector -describe('Drain Account tests', { defaultCommandTimeout: 12000 }, () => { +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Drain Account tests', { defaultCommandTimeout: 40000 }, () => { before(async () => { safeAppSafes = await getSafes(CATEGORIES.safeapps) }) @@ -17,36 +21,37 @@ describe('Drain Account tests', { defaultCommandTimeout: 12000 }, () => { const appUrl = constants.drainAccount_url iframeSelector = `iframe[id="iframe-${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.clearLocalStorage() cy.visit(visitUrl) - main.acceptCookies() - safeapps.clickOnContinueBtn() }) it('Verify drain can be created', () => { + wallet.connectSigner(signer) cy.enter(iframeSelector).then((getBody) => { - getBody().findByLabelText(safeapps.recipientStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) + getBody().findByLabelText(safeapps.recipientStr).type(getMockAddress()) getBody().findAllByText(safeapps.transferEverythingStr).click() }) cy.findByRole('button', { name: safeapps.testTransfer1 }) cy.findByRole('button', { name: safeapps.nativeTransfer2 }) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() }) it('Verify partial drain can be created', () => { + wallet.connectSigner(signer) cy.enter(iframeSelector).then((getBody) => { getBody().findByLabelText(safeapps.selectAllRowsChbxStr).click() getBody().findAllByLabelText(safeapps.selectRowChbxStr).eq(1).click() getBody().findAllByLabelText(safeapps.selectRowChbxStr).eq(2).click() - getBody().findByLabelText(safeapps.recipientStr).clear().type(safeAppSafes.SEP_SAFEAPP_SAFE_2) + getBody().findByLabelText(safeapps.recipientStr).clear().type(getMockAddress()) getBody().findAllByText(safeapps.transfer2AssetsStr).click() }) cy.findByRole('button', { name: safeapps.testTransfer2 }) cy.findByRole('button', { name: safeapps.nativeTransfer1 }) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() }) // TODO: ENS does not resolve @@ -61,10 +66,10 @@ describe('Drain Account tests', { defaultCommandTimeout: 12000 }, () => { it('Verify when cancelling a drain, previous data is preserved', () => { cy.enter(iframeSelector).then((getBody) => { - getBody().findByLabelText(safeapps.recipientStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) + getBody().findByLabelText(safeapps.recipientStr).type(getMockAddress()) getBody().findAllByText(safeapps.transferEverythingStr).click() }) - navigation.clickOnModalCloseBtn() + navigation.clickOnModalCloseBtn(0) cy.enter(iframeSelector).then((getBody) => { getBody().findAllByText(safeapps.transferEverythingStr).should('be.visible') }) @@ -79,7 +84,7 @@ describe('Drain Account tests', { defaultCommandTimeout: 12000 }, () => { it('Verify a drain cannot be created with invalid recipient selected', () => { cy.enter(iframeSelector).then((getBody) => { - getBody().findByLabelText(safeapps.recipientStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2.substring(1)) + getBody().findByLabelText(safeapps.recipientStr).type(getMockAddress().substring(1)) getBody().findAllByText(safeapps.transferEverythingStr).click() getBody().findByText(safeapps.validRecipientAddressStr) }) @@ -88,7 +93,7 @@ describe('Drain Account tests', { defaultCommandTimeout: 12000 }, () => { it('Verify a drain cannot be created when no assets are selected', () => { cy.enter(iframeSelector).then((getBody) => { getBody().findByLabelText(safeapps.selectAllRowsChbxStr).click() - getBody().findByLabelText(safeapps.recipientStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) + getBody().findByLabelText(safeapps.recipientStr).type(getMockAddress()) getBody().findAllByText(safeapps.noTokensSelectedStr).should('be.visible') }) }) diff --git a/apps/web/cypress/e2e/safe-apps/info_modal.cy.js b/apps/web/cypress/e2e/safe-apps/info_modal.cy.js new file mode 100644 index 000000000..fbd93cb3c --- /dev/null +++ b/apps/web/cypress/e2e/safe-apps/info_modal.cy.js @@ -0,0 +1,27 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safeapps from '../pages/safeapps.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +describe('Info modal tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(`${constants.appsUrl}?safe=${staticSafes.SEP_STATIC_SAFE_2}`, { + failOnStatusCode: false, + }) + }) + + it('Verify the disclaimer is displayed when a Safe App is opened', () => { + // Required to show disclaimer + cy.clearLocalStorage() + main.acceptCookies() + safeapps.clickOnApp(safeapps.transactionBuilderStr) + safeapps.clickOnOpenSafeAppBtn() + safeapps.verifyDisclaimerIsDisplayed() + }) +}) diff --git a/cypress/e2e/safe-apps/permissions_settings.cy.js b/apps/web/cypress/e2e/safe-apps/permissions_settings.cy.js similarity index 99% rename from cypress/e2e/safe-apps/permissions_settings.cy.js rename to apps/web/cypress/e2e/safe-apps/permissions_settings.cy.js index 53df8b6ce..ee1d65e32 100644 --- a/cypress/e2e/safe-apps/permissions_settings.cy.js +++ b/apps/web/cypress/e2e/safe-apps/permissions_settings.cy.js @@ -13,7 +13,6 @@ describe.skip('Permissions settings tests', () => { before(() => { getSafes(CATEGORIES.static).then((statics) => { staticSafes = statics - cy.clearLocalStorage() cy.on('window:before:load', (window) => { window.localStorage.setItem( constants.BROWSER_PERMISSIONS_KEY, @@ -52,7 +51,6 @@ describe.skip('Permissions settings tests', () => { cy.visit(`${constants.appSettingsUrl}?safe=${staticSafes.SEP_STATIC_SAFE_2}`, { failOnStatusCode: false, }) - main.acceptCookies() }) }) diff --git a/cypress/e2e/safe-apps/preview_drawer.cy.js b/apps/web/cypress/e2e/safe-apps/preview_drawer.cy.js similarity index 94% rename from cypress/e2e/safe-apps/preview_drawer.cy.js rename to apps/web/cypress/e2e/safe-apps/preview_drawer.cy.js index e24c590fa..d20ee5fca 100644 --- a/cypress/e2e/safe-apps/preview_drawer.cy.js +++ b/apps/web/cypress/e2e/safe-apps/preview_drawer.cy.js @@ -2,6 +2,7 @@ import * as constants from '../../support/constants' import * as main from '../pages/main.page' import * as safeapps from '../pages/safeapps.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as ls from '../../support/localstorage_data.js' let staticSafes = [] @@ -11,11 +12,9 @@ describe('Preview drawer tests', () => { }) beforeEach(() => { - cy.clearLocalStorage() cy.visit(`${constants.appsUrl}?safe=${staticSafes.SEP_STATIC_SAFE_2}`, { failOnStatusCode: false, }) - main.acceptCookies() }) it('Verify the preview drawer is displayed when opening a Safe App from the app list', () => { diff --git a/cypress/e2e/safe-apps/safe_permissions.cy.js b/apps/web/cypress/e2e/safe-apps/safe_permissions.cy.js similarity index 86% rename from cypress/e2e/safe-apps/safe_permissions.cy.js rename to apps/web/cypress/e2e/safe-apps/safe_permissions.cy.js index 0b64e41ad..d91082cbf 100644 --- a/cypress/e2e/safe-apps/safe_permissions.cy.js +++ b/apps/web/cypress/e2e/safe-apps/safe_permissions.cy.js @@ -1,10 +1,10 @@ import * as constants from '../../support/constants' import * as safeapps from '../pages/safeapps.pages' import * as main from '../pages/main.page' +import * as ls from '../../support/localstorage_data.js' describe('Safe permissions system tests', () => { beforeEach(() => { - cy.clearLocalStorage() cy.fixture('safe-app').then((html) => { cy.intercept('GET', `${constants.testAppUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { @@ -17,11 +17,6 @@ describe('Safe permissions system tests', () => { it('Verify that requesting permissions with wallet_requestPermissions shows the permissions prompt and return the permissions on accept', () => { cy.visitSafeApp(constants.testAppUrl + constants.requestPermissionsUrl) - main.acceptCookies() - safeapps.clickOnContinueBtn() - safeapps.verifyWarningDefaultAppMsgIsDisplayed() - safeapps.clickOnContinueBtn() - safeapps.verifyPermissionsRequestExists() safeapps.verifyAccessToAddressBookExists() safeapps.clickOnAcceptBtn() @@ -56,11 +51,6 @@ describe('Safe permissions system tests', () => { }) cy.visitSafeApp(constants.testAppUrl + constants.getPermissionsUrl) - main.acceptCookies() - safeapps.clickOnContinueBtn() - safeapps.verifyWarningDefaultAppMsgIsDisplayed() - safeapps.clickOnContinueBtn() - cy.get('@safeAppsMessage').should('have.been.calledWithMatch', { data: [ { diff --git a/apps/web/cypress/e2e/safe-apps/tx-builder.2spec.cy.js b/apps/web/cypress/e2e/safe-apps/tx-builder.2spec.cy.js new file mode 100644 index 000000000..3353fe640 --- /dev/null +++ b/apps/web/cypress/e2e/safe-apps/tx-builder.2spec.cy.js @@ -0,0 +1,172 @@ +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) + }) + + beforeEach(() => { + const appUrl = constants.TX_Builder_url + iframeSelector = `iframe[id="iframe-${appUrl}"]` + const visitUrl = `/apps/open?safe=${safeAppSafes.SEP_SAFEAPP_SAFE_1}&appUrl=${encodeURIComponent(appUrl)}` + cy.visit(visitUrl) + }) + + it('Verify a batch cannot be created without method data', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(getMockAddress()) + getBody().findByText(safeapps.addTransactionStr).click() + getBody() + .findAllByText(safeapps.requiredStr) + .then(($element) => { + const color = $element.css('color') + expect(utils.isInRedRange(color), 'Element color is ').to.be.true + }) + }) + }) + + it('Verify a batch can be uploaded, saved to library, downloaded and removed', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findAllByText('choose a file').attachFile('test-working-batch.json', { subjectType: 'drag-n-drop' }) + getBody().findAllByText('uploaded').wait(300) + getBody().find(safeapps.saveToLibraryBtn).click() + getBody().findByLabelText(safeapps.batchNameStr).type(safeapps.e3eTestStr) + getBody().findAllByText(safeapps.createBtnStr).should('not.be.disabled').click() + getBody().findByText(safeapps.transactionLibraryStr).click() + getBody().find(safeapps.downloadBatchBtn).click() + getBody().find(safeapps.deleteBatchBtn).click() + getBody().findAllByText(safeapps.confirmDeleteBtnStr).should('not.be.disabled').click() + getBody().findByText(safeapps.noSavedBatchesStr).should('be.visible') + getBody().findByText(safeapps.backToTransactionStr).should('be.visible') + }) + cy.readFile('cypress/downloads/E2E test.json').should('exist') + }) + + it('Verify there is notification if uploaded batch is from a different chain', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findAllByText('choose a file').attachFile('test-mainnet-batch.json', { subjectType: 'drag-n-drop' }) + getBody().findAllByText(safeapps.warningStr).should('be.visible') + getBody().findAllByText(safeapps.anotherChainStr).should('be.visible') + }) + }) + + it('Verify there is error message when a modified batch is uploaded', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findAllByText('choose a file').attachFile('test-modified-batch.json', { subjectType: 'drag-n-drop' }) + getBody().findAllByText(safeapps.changedPropertiesStr) + getBody().findAllByText('choose a file').should('be.visible') + }) + }) + + it('Verify an invalid batch cannot be uploaded', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody() + .findAllByText('choose a file') + .attachFile('test-invalid-batch.json', { subjectType: 'drag-n-drop' }) + .findAllByText('choose a file') + .should('be.visible') + }) + }) + + it('Verify an empty batch cannot be uploaded', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody() + .findAllByText('choose a file') + .attachFile('test-empty-batch.json', { subjectType: 'drag-n-drop' }) + .findAllByText('choose a file') + .should('be.visible') + }) + }) + + it('Verify a valid batch as successful can be simulated', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) + getBody().findByText(safeapps.keepProxiABIStr).click() + getBody().findByLabelText(safeapps.tokenAmount).type('0') + getBody().findByText(safeapps.addTransactionStr).click() + getBody().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.simulateBtnStr).click() + getBody().findByText(safeapps.transferStr).should('be.visible') + getBody().findByText(safeapps.successStr).should('be.visible') + }) + }) + + it('Verify an invalid batch as failed can be simulated', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) + getBody().findByText(safeapps.keepProxiABIStr).click() + getBody().findByLabelText(safeapps.tokenAmount).type('100') + getBody().findByText(safeapps.addTransactionStr).click() + getBody().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.simulateBtnStr).click() + 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.spec.cy.js b/apps/web/cypress/e2e/safe-apps/tx-builder.spec.cy.js new file mode 100644 index 000000000..7c018d62c --- /dev/null +++ b/apps/web/cypress/e2e/safe-apps/tx-builder.spec.cy.js @@ -0,0 +1,234 @@ +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 { 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) + }) + + beforeEach(() => { + const appUrl = constants.TX_Builder_url + iframeSelector = `iframe[id="iframe-${appUrl}"]` + const visitUrl = `/apps/open?safe=${safeAppSafes.SEP_SAFEAPP_SAFE_1}&appUrl=${encodeURIComponent(appUrl)}` + cy.visit(visitUrl) + }) + + it('Verify a simple batch can be created', () => { + 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() + }) + + cy.get('h4').contains(safeapps.transactionBuilderStr).should('be.visible') + navigation.clickOnModalCloseBtn(0) + cy.enter(iframeSelector).then((getBody) => { + getBody().findAllByText(constants.SEPOLIA_CONTRACT_SHORT).should('have.length', 1) + getBody().findByText(safeapps.testAddressValueStr).should('exist') + }) + }) + + it('Verify a complex batch can be created', () => { + 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.testBooleanValue }).click() + getBody().findByText(safeapps.addTransactionStr).click() + + getBody().find(safeapps.contractMethodIndex).parent().click() + getBody().findByRole('option', { name: safeapps.testBooleanValue }).click() + getBody().findByText(safeapps.addTransactionStr).click() + + getBody().find(safeapps.contractMethodIndex).parent().click() + getBody().findByRole('option', { name: safeapps.testBooleanValue }).click() + getBody().findByText(safeapps.addTransactionStr).click() + + getBody().findAllByText(constants.SEPOLIA_CONTRACT_SHORT).should('have.length', 3) + getBody().findAllByText(safeapps.testBooleanValue).should('have.length', 3) + + getBody().findByText(safeapps.createBatchStr).click() + 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) + getBody().findAllByText(safeapps.testBooleanValue).should('have.length', 3) + }) + }) + + // TODO: Fix this test once Sepolia ENS works in tx builder + it.skip('Verify a batch can be created using ENS name', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(constants.ENS_TEST_SEPOLIA) + getBody().findByRole('button', { name: safeapps.useImplementationABI }).click() + getBody().findByLabelText(safeapps.ownerAddressStr).type(constants.SAFE_APP_ADDRESS_2) + getBody().findByLabelText(safeapps.thresholdStr).type('1') + getBody().findByText(safeapps.addTransactionStr).click() + getBody().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.sendBatchStr).click() + }) + cy.findByRole('button', { name: safeapps.transactionDetailsStr }).click() + cy.findByRole('region').should('exist') + cy.findByText(safeapps.addOwnerWithThreshold).should('exist') + cy.contains(safeapps.ownerAddressStr2).should('exist') + cy.findAllByText(constants.SAFE_APP_ADDRESS_2_SHORT).should('have.length', 1) + cy.findByText(safeapps.thresholdStr2).should('exist') + }) + + it('Verify a batch can be created from an ABI', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterABIStr).type(safeapps.abi, { parseSpecialCharSequences: false }) + getBody().findByLabelText(safeapps.toAddressStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) + getBody().findByLabelText(safeapps.tokenAmount).type('0') + getBody().findByText(safeapps.addTransactionStr).click() + + getBody().findAllByText(constants.SEPOLIA_RECIPIENT_ADDR_SHORT).should('have.length', 1) + getBody().findAllByText(safeapps.testFallback).should('have.length', 1) + + getBody().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.sendBatchStr).click() + }) + cy.get('h4').contains(safeapps.transactionBuilderStr).should('be.visible') + navigation.clickOnModalCloseBtn(0) + cy.enter(iframeSelector).then((getBody) => { + getBody().findAllByText(constants.SEPOLIA_RECIPIENT_ADDR_SHORT).should('have.length', 1) + getBody().findAllByText(safeapps.testFallback).should('have.length', 1) + }) + }) + + it('Verify a batch with custom data can be created', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().find('.MuiSwitch-root').click() + getBody().findByLabelText(safeapps.enterAddressStr).type(constants.SAFE_APP_ADDRESS) + getBody().findByLabelText(safeapps.tokenAmount).type('0') + getBody().findByLabelText(safeapps.dataStr).type('0x') + getBody().findByText(safeapps.addTransactionStr).click() + + getBody().findAllByText(constants.SEPOLIA_CONTRACT_SHORT).should('have.length', 1) + getBody().findAllByText(safeapps.customData).should('have.length', 1) + + getBody().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.sendBatchStr).click() + }) + cy.get('h4').contains(safeapps.transactionBuilderStr).should('be.visible') + navigation.clickOnModalCloseBtn(0) + cy.enter(iframeSelector).then((getBody) => { + getBody().findAllByText(constants.SEPOLIA_CONTRACT_SHORT).should('have.length', 1) + getBody().findAllByText(safeapps.customData).should('have.length', 1) + }) + }) + + it('Verify a batch can be cancelled', () => { + 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().findByText(safeapps.createBatchStr).click() + getBody().findByRole('button', { name: safeapps.cancelBtnStr }).click() + getBody().findByText(safeapps.clearTransactionListStr) + getBody().findByRole('button', { name: safeapps.confirmClearTransactionListStr }).click() + getBody().findAllByText('choose a file').should('be.visible') + }) + }) + + it('Verify cancel operation can be reverted', () => { + 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().findByText(safeapps.createBatchStr).click() + getBody().findByRole('button', { name: safeapps.cancelBtnStr }).click() + getBody().findByText(safeapps.clearTransactionListStr) + getBody().findByRole('button', { name: safeapps.backBtnStr }).click() + getBody().findByText(safeapps.reviewAndConfirmStr).should('be.visible') + }) + }) + + it('Verify it is allowed to go back without removing data and add more transactions to the batch', () => { + 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().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.backToTransactionStr).click() + 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().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.sendBatchStr).click() + }) + cy.get('h4').contains(safeapps.transactionBuilderStr).should('be.visible') + cy.findAllByText(safeapps.testAddressValueStr).should('have.length', 4) + 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) + }) + }) + + it('Verify a batch cannot be created with invalid address', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2.substring(5)) + getBody() + .findAllByText(safeapps.addressNotValidStr) + .then(($element) => { + const color = $element.css('color') + expect(utils.isInRedRange(color), 'Element color is ').to.be.true + }) + }) + }) + + it('Verify a batch cannot be created without asset amount', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) + getBody().findByText(safeapps.keepProxiABIStr).click() + getBody().findByText(safeapps.addTransactionStr).click() + getBody() + .findAllByText(safeapps.requiredStr) + .then(($element) => { + const color = $element.css('color') + expect(utils.isInRedRange(color), 'Element color is ').to.be.true + }) + }) + }) + + it('Verify that error types are not displayed in ABI methods', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterABIStr).type(safeapps.abi, { parseSpecialCharSequences: false }) + getBody().find(safeapps.contractMethodSelector).click() + getBody().find(safeapps.AddressEmptyCodeStr).should('not.exist') + }) + }) +}) diff --git a/cypress/e2e/safe-apps/tx_modal.cy.js b/apps/web/cypress/e2e/safe-apps/tx_modal.cy.js similarity index 80% rename from cypress/e2e/safe-apps/tx_modal.cy.js rename to apps/web/cypress/e2e/safe-apps/tx_modal.cy.js index 2549e3aa6..3b33dc219 100644 --- a/cypress/e2e/safe-apps/tx_modal.cy.js +++ b/apps/web/cypress/e2e/safe-apps/tx_modal.cy.js @@ -1,6 +1,6 @@ import * as constants from '../../support/constants' import * as main from '../pages/main.page' -import * as safeapps from '../pages/safeapps.pages' +import * as ls from '../../support/localstorage_data.js' const testAppName = 'Cypress Test App' const testAppDescr = 'Cypress Test App Description' @@ -9,7 +9,6 @@ const confirmTx = 'Confirm transaction' describe('Transaction modal tests', () => { beforeEach(() => { - cy.clearLocalStorage() cy.fixture('safe-app').then((html) => { cy.intercept('GET', `${constants.testAppUrl}/*`, html) cy.intercept('GET', `*/manifest.json`, { @@ -25,10 +24,6 @@ describe('Transaction modal tests', () => { { defaultCommandTimeout: 12000 }, () => { cy.visitSafeApp(`${constants.testAppUrl}/dummy`) - main.acceptCookies() - safeapps.clickOnContinueBtn() - safeapps.verifyWarningDefaultAppMsgIsDisplayed() - safeapps.clickOnContinueBtn() 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 new file mode 100644 index 000000000..8eb448ffa --- /dev/null +++ b/apps/web/cypress/e2e/smoke/add_owner.cy.js @@ -0,0 +1,53 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' +import * as navigation from '../pages/navigation.page' +import * as wallet from '../../support/utils/wallet.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('[SMOKE] Add Owners tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) + main.waitForHistoryCallToComplete() + main.verifyElementsExist([navigation.setupSection]) + }) + + // 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.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidFormat) + + owner.typeOwnerAddress(constants.addresBookContacts.user1.address.toUpperCase()) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + owner.typeOwnerAddress(staticSafes.SEP_STATIC_SAFE_4) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownSafe) + + owner.typeOwnerAddress(constants.addresBookContacts.user1.address.replace('F', 'f')) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) + + owner.typeOwnerAddress(constants.DEFAULT_OWNER_ADDRESS) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.alreadyAdded) + }) + + it('[SMOKE] Verify the presence of "Add Owner" button', () => { + wallet.connectSigner(signer) + owner.verifyAddOwnerBtnIsEnabled() + }) + + it('[SMOKE] Verify “Add new owner” button is disabled for Non-Owner', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_3) + main.waitForHistoryCallToComplete() + owner.verifyAddOwnerBtnIsDisabled() + }) +}) diff --git a/apps/web/cypress/e2e/smoke/address_book.cy.js b/apps/web/cypress/e2e/smoke/address_book.cy.js new file mode 100644 index 000000000..0255458fe --- /dev/null +++ b/apps/web/cypress/e2e/smoke/address_book.cy.js @@ -0,0 +1,27 @@ +import 'cypress-file-upload' +import * as constants from '../../support/constants' +import * as addressBook from '../../e2e/pages/address_book.page' +import * as main from '../../e2e/pages/main.page' +import * as ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +describe('[SMOKE] Address book tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.addressBookUrl + staticSafes.SEP_STATIC_SAFE_4) + main.waitForHistoryCallToComplete() + }) + + it('[SMOKE] Verify empty name is not allowed when editing', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1) + cy.wait(1000) + cy.reload() + addressBook.clickOnEditEntryBtn() + addressBook.verifyEmptyOwnerNameNotAllowed() + }) +}) diff --git a/apps/web/cypress/e2e/smoke/assets.cy.js b/apps/web/cypress/e2e/smoke/assets.cy.js new file mode 100644 index 000000000..b3a003741 --- /dev/null +++ b/apps/web/cypress/e2e/smoke/assets.cy.js @@ -0,0 +1,48 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as assets from '../pages/assets.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as ls from '../../support/localstorage_data.js' + +let staticSafes = [] + +describe('[SMOKE] Assets tests', () => { + const fiatRegex = assets.fiatRegex + + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_2) + }) + + it('[SMOKE] Verify that the native token is visible', () => { + assets.verifyTokenIsPresent(constants.tokenNames.sepoliaEther) + }) + + it('[SMOKE] Verify that the token tab is selected by default and the table is visible', () => { + assets.verifyTokensTabIsSelected('true') + }) + + it('[SMOKE] Verify that Token list dropdown down options show/hide spam tokens', () => { + let spamTokens = [ + assets.currencyAave, + assets.currencyTestTokenA, + assets.currencyTestTokenB, + assets.currencyUSDC, + assets.currencyLink, + 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) + }) + }) +}) diff --git a/apps/web/cypress/e2e/smoke/batch_tx.cy.js b/apps/web/cypress/e2e/smoke/batch_tx.cy.js new file mode 100644 index 000000000..12e455ecd --- /dev/null +++ b/apps/web/cypress/e2e/smoke/batch_tx.cy.js @@ -0,0 +1,58 @@ +import * as batch from '../pages/batches.pages' +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as ls from '../../support/localstorage_data.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +let staticSafes = [] + +const currentNonce = 3 +const funds_first_tx = '0.001' +const funds_second_tx = '0.002' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('[SMOKE] Batch transaction tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_2) + }) + + it('[SMOKE] Verify empty batch list can be opened', () => { + batch.openBatchtransactionsModal() + cy.contains(batch.addInitialTransactionStr).should('be.visible') + }) + + it('[SMOKE] Verify a transaction is visible in a batch', () => { + cy.wrap(null) + .then(() => main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__batch, ls.batchData.entry1)) + .then(() => { + cy.reload() + batch.verifyBatchIconCount(1) + batch.clickOnBatchCounter() + batch.verifyAmountTransactionsInBatch(1) + }) + }) + + it('[SMOKE] Verify the batch can be confirmed and related transactions exist in the form', () => { + cy.wrap(null) + .then(() => main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__batch, ls.batchData.entry0)) + .then(() => main.isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__batch, ls.batchData.entry0)) + .then(() => { + cy.reload() + wallet.connectSigner(signer) + batch.clickOnBatchCounter() + batch.clickOnConfirmBatchBtn() + batch.verifyBatchTransactionsCount(2) + batch.clickOnBatchCounter() + cy.contains(funds_first_tx).parents('ul').as('TransactionList') + cy.get('@TransactionList').find('li').eq(0).contains(funds_first_tx) + cy.get('@TransactionList').find('li').eq(1).contains(funds_second_tx) + }) + }) +}) diff --git a/apps/web/cypress/e2e/smoke/create_tx.cy.js b/apps/web/cypress/e2e/smoke/create_tx.cy.js new file mode 100644 index 000000000..af0b9ba05 --- /dev/null +++ b/apps/web/cypress/e2e/smoke/create_tx.cy.js @@ -0,0 +1,54 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as createtx from '../../e2e/pages/create_tx.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +let staticSafes = [] + +const currentNonce = 5 + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('[SMOKE] Create transactions tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_10) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + }) + + it('[SMOKE] Verify MaxAmount button', () => { + createtx.setMaxAmount() + createtx.verifyMaxAmount(constants.tokenNames.sepoliaEther, constants.tokenAbbreviation.sep) + }) + + it('[SMOKE] Verify error messages for invalid address input', () => { + createtx.verifyRandomStringAddress('Lorem Ipsum') + createtx.verifyWrongChecksum(constants.WRONGLY_CHECKSUMMED_ADDRESS) + }) + + it('[SMOKE] Verify address input resolves a valid ENS name', () => { + createtx.typeRecipientAddress(constants.ENS_TEST_SEPOLIA) + createtx.verifyENSResolves(staticSafes.SEP_STATIC_SAFE_6) + }) + + it('[SMOKE] Verify error message for invalid amount input', () => { + createtx.clickOnTokenselectorAndSelectSepoliaEth() + createtx.verifyAmountLargerThanCurrentBalance() + }) + + it('[SMOKE] Verify nonce tooltip warning messages', () => { + createtx.changeNonce(0) + createtx.verifyTooltipMessage(constants.nonceTooltipMsg.lowerThanCurrent + currentNonce.toString()) + createtx.changeNonce(currentNonce + 53) + createtx.verifyTooltipMessage(constants.nonceTooltipMsg.higherThanRecommended) + createtx.changeNonce(currentNonce + 150) + createtx.verifyTooltipMessage(constants.nonceTooltipMsg.muchHigherThanRecommended) + }) +}) diff --git a/apps/web/cypress/e2e/smoke/dashboard.cy.js b/apps/web/cypress/e2e/smoke/dashboard.cy.js new file mode 100644 index 000000000..752e2e2c7 --- /dev/null +++ b/apps/web/cypress/e2e/smoke/dashboard.cy.js @@ -0,0 +1,53 @@ +import * as constants from '../../support/constants' +import * as dashboard from '../pages/dashboard.pages' +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'] + +describe('[SMOKE] Dashboard tests', { defaultCommandTimeout: 60000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_2) + }) + + it('[SMOKE] Verify the overview widget is displayed', () => { + dashboard.verifyOverviewWidgetData() + }) + + it('[SMOKE] Verify the transaction queue widget is displayed', () => { + dashboard.verifyTxQueueWidget() + }) + + it('[SMOKE] Verify the Safe Apps Section is displayed', () => { + 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() + }) + + it('[SMOKE] Verify that the last created tx in conflicting tx is showed in the widget', () => { + 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/cypress/e2e/smoke/import_export_data.cy.js b/apps/web/cypress/e2e/smoke/import_export_data.cy.js similarity index 93% rename from cypress/e2e/smoke/import_export_data.cy.js rename to apps/web/cypress/e2e/smoke/import_export_data.cy.js index 6ecc9d6dd..cfeda752e 100644 --- a/cypress/e2e/smoke/import_export_data.cy.js +++ b/apps/web/cypress/e2e/smoke/import_export_data.cy.js @@ -3,23 +3,18 @@ import * as file from '../pages/import_export.pages' import * as main from '../pages/main.page' import * as constants from '../../support/constants' import * as ls from '../../support/localstorage_data.js' -import * as createwallet from '../pages/create_wallet.pages' import * as sideBar from '../pages/sidebar.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' let staticSafes = [] -describe('[SMOKE] Import Export Data tests', () => { +describe('[SMOKE] Import Export Data tests', { defaultCommandTimeout: 20000 }, () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) }) beforeEach(() => { - cy.clearLocalStorage() - cy.visit(constants.dataSettingsUrl).then(() => { - main.acceptCookies() - createwallet.selectNetwork(constants.networks.sepolia) - }) + cy.visit(constants.dataSettingsUrl) }) it('[SMOKE] Verify Safe can be accessed after test file upload', () => { diff --git a/apps/web/cypress/e2e/smoke/import_export_data_2.cy.js b/apps/web/cypress/e2e/smoke/import_export_data_2.cy.js new file mode 100644 index 000000000..33e7a2975 --- /dev/null +++ b/apps/web/cypress/e2e/smoke/import_export_data_2.cy.js @@ -0,0 +1,27 @@ +import 'cypress-file-upload' +import * as file from '../pages/import_export.pages.js' +import * as constants from '../../support/constants.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +describe('[SMOKE] Import Export Data tests 2', { defaultCommandTimeout: 20000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_13) + }) + + it('[SMOKE] Verify the Import section is on the Global settings', () => { + cy.visit(constants.dataSettingsUrl + staticSafes.SEP_STATIC_SAFE_13) + file.verifyImportSectionVisible() + file.verifyValidImportInputExists() + }) + + it('[SMOKE] Verify that the Export section is present in the safe settings', () => { + cy.visit(constants.dataSettingsUrl + staticSafes.SEP_STATIC_SAFE_13) + file.verifyExportFileSectionIsVisible() + }) +}) diff --git a/cypress/e2e/smoke/landing.cy.js b/apps/web/cypress/e2e/smoke/landing.cy.js similarity index 90% rename from cypress/e2e/smoke/landing.cy.js rename to apps/web/cypress/e2e/smoke/landing.cy.js index f56883364..fe61ffea7 100644 --- a/cypress/e2e/smoke/landing.cy.js +++ b/apps/web/cypress/e2e/smoke/landing.cy.js @@ -1,7 +1,6 @@ import * as constants from '../../support/constants' describe('[SMOKE] Landing page tests', () => { it('[SMOKE] Verify a user will be redirected to welcome page', () => { - cy.clearLocalStorage() cy.visit('/') cy.url().should('include', constants.welcomeUrl) }) diff --git a/apps/web/cypress/e2e/smoke/load_safe.cy.js b/apps/web/cypress/e2e/smoke/load_safe.cy.js new file mode 100644 index 000000000..e8c90813d --- /dev/null +++ b/apps/web/cypress/e2e/smoke/load_safe.cy.js @@ -0,0 +1,60 @@ +import 'cypress-file-upload' +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as safe from '../pages/load_safe.pages' +import * as createwallet from '../pages/create_wallet.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +const testSafeName = 'Test safe name' + +describe('[SMOKE] Load Safe tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.loadNewSafeSepoliaUrl) + }) + + it('[SMOKE] Verify only valid Safe name can be accepted', () => { + // alias the address input label + cy.get('input[name="address"]').parent().prev('label').as('addressLabel') + + createwallet.verifyDefaultWalletName(createwallet.defaultSepoliaPlaceholder) + safe.verifyIncorrectAddressErrorMessage() + safe.inputNameAndAddress(testSafeName, staticSafes.SEP_STATIC_SAFE_4) + + safe.verifyAddressInputValue(staticSafes.SEP_STATIC_SAFE_4) + safe.verifyNextButtonStatus('be.enabled') + }) + + it('[SMOKE] Verify names cannot have more than 50 characters', () => { + 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') + }) + + it('[SMOKE] Verify safe name has a default name', () => { + createwallet.verifyDefaultWalletName(createwallet.defaultSepoliaPlaceholder) + cy.reload() + 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 new file mode 100644 index 000000000..9d99016a5 --- /dev/null +++ b/apps/web/cypress/e2e/smoke/messages_offchain.cy.js @@ -0,0 +1,72 @@ +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 msg_data from '../../fixtures/txmessages_data.json' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +const typeMessagesGeneral = msg_data.type.general +const typeMessagesOffchain = msg_data.type.offChain + +describe('[SMOKE] Offchain Messages tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_10) + }) + + it('[SMOKE] Verify summary for off-chain unsigned messages', () => { + createTx.verifySummaryByIndex(0, [ + typeMessagesGeneral.sign, + typeMessagesGeneral.oneOftwo, + typeMessagesOffchain.testMessage1, + ]) + createTx.verifySummaryByIndex(2, [ + typeMessagesGeneral.sign, + typeMessagesGeneral.oneOftwo, + typeMessagesOffchain.testMessage2, + ]) + }) + + it('[SMOKE] Verify summary for off-chain signed messages', () => { + createTx.verifySummaryByIndex(1, [ + typeMessagesGeneral.sign, + typeMessagesGeneral.oneOftwo, + typeMessagesOffchain.name, + ]) + createTx.verifySummaryByIndex(3, [ + typeMessagesGeneral.sign, + typeMessagesGeneral.oneOftwo, + typeMessagesOffchain.testMessage3, + ]) + }) + + it('[SMOKE] Verify exapanded details for EIP 191 off-chain message', () => { + createTx.clickOnTransactionItemByIndex(2) + cy.contains(typeMessagesOffchain.message2).should('be.visible') + }) + + it('[SMOKE] Verify exapanded details for EIP 712 off-chain message', () => { + 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/smoke/nfts.cy.js b/apps/web/cypress/e2e/smoke/nfts.cy.js new file mode 100644 index 000000000..1ad6a72e8 --- /dev/null +++ b/apps/web/cypress/e2e/smoke/nfts.cy.js @@ -0,0 +1,41 @@ +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' + +let staticSafes = [] + +const nftsName = 'CatFactory' +const nftsAddress = '0x373B...866c' +const nftsTokenID = 'CF' + +describe('[SMOKE] NFTs tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_2) + nfts.waitForNftItems(2) + }) + + it('[SMOKE] Verify that NFTs exist in the table', () => { + nfts.verifyNFTNumber(10) + }) + + 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() + }) + + it('[SMOKE] Verify NFT open does not open if no NFT exits', () => { + nfts.clickOnInactiveNFT() + nfts.verifyNFTModalDoesNotExist() + }) +}) diff --git a/apps/web/cypress/e2e/smoke/replace_owner.cy.js b/apps/web/cypress/e2e/smoke/replace_owner.cy.js new file mode 100644 index 000000000..b05e400da --- /dev/null +++ b/apps/web/cypress/e2e/smoke/replace_owner.cy.js @@ -0,0 +1,36 @@ +import * as constants from '../../support/constants' +import * as main from '../../e2e/pages/main.page' +import * as owner from '../pages/owners.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +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 + +describe('[SMOKE] Replace Owners tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) + cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + }) + + it('[SMOKE] Verify that "Replace" icon is visible', () => { + wallet.connectSigner(signer) + owner.verifyReplaceBtnIsEnabled() + }) + + it('[SMOKE] Verify owner replace button is disabled for Non-Owner', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_3) + owner.verifyReplaceBtnIsDisabled() + }) + + it('[SMOKE] Verify that the owner replacement form is opened', () => { + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + owner.openReplaceOwnerWindow(0) + }) +}) diff --git a/apps/web/cypress/e2e/smoke/spending_limits.cy.js b/apps/web/cypress/e2e/smoke/spending_limits.cy.js new file mode 100644 index 000000000..ac080d65d --- /dev/null +++ b/apps/web/cypress/e2e/smoke/spending_limits.cy.js @@ -0,0 +1,63 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as spendinglimit from '../pages/spending_limits.pages' +import * as owner from '../pages/owners.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +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 + +describe('[SMOKE] Spending limits tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_8) + wallet.connectSigner(signer) + owner.waitForConnectionStatus() + cy.get(spendinglimit.spendingLimitsSection).should('be.visible') + spendinglimit.clickOnNewSpendingLimitBtn() + }) + + it('Verify A valid ENS name is resolved successfully', () => { + spendinglimit.enterBeneficiaryAddress(constants.ENS_TEST_SEPOLIA) + spendinglimit.checkBeneficiaryENS(staticSafes.SEP_STATIC_SAFE_6) + }) + + it('Verify writing a valid address shows no errors', () => { + spendinglimit.enterBeneficiaryAddress(staticSafes.SEP_STATIC_SAFE_6) + spendinglimit.verifyValidAddressShowsNoErrors() + }) + + it('Verify Amount input cannot be 0', () => { + spendinglimit.enterSpendingLimitAmount('0') + spendinglimit.verifyNumberErrorValidation() + }) + + it('Verify Amount input cannot be a negative number', () => { + spendinglimit.enterSpendingLimitAmount('-1') + spendinglimit.verifyNumberAmountEntered('1') + }) + + it('Verify Amount input cannot be characters', () => { + spendinglimit.enterSpendingLimitAmount('abc') + spendinglimit.verifyNumberAmountEntered('') + }) + + it('Verify any positive number can be set in the amount input', () => { + spendinglimit.enterSpendingLimitAmount(1) + spendinglimit.verifyValidAddressShowsNoErrors() + }) + + it('Verify the reset time is "One time" by default', () => { + spendinglimit.verifyDefaultTimeIsSet() + }) + + it('Validate Reset values present in dropdown: One time, 5 minutes, 30 minutes, 1 hr', () => { + spendinglimit.clickOnTimePeriodDropdown() + spendinglimit.checkTimeDropdownOptions() + }) +}) diff --git a/apps/web/cypress/e2e/smoke/tokens.cy.js b/apps/web/cypress/e2e/smoke/tokens.cy.js new file mode 100644 index 000000000..1c5957ea5 --- /dev/null +++ b/apps/web/cypress/e2e/smoke/tokens.cy.js @@ -0,0 +1,27 @@ +import * as constants from '../../support/constants' +import * as main from '../pages/main.page' +import * as assets from '../pages/assets.pages' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as ls from '../../support/localstorage_data.js' + +let staticSafes = [] + +describe('[SMOKE] Tokens tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + beforeEach(() => { + main.addToLocalStorage( + constants.localStorageKeys.SAFE_v2__tokenlist_onboarding, + ls.cookies.acceptedTokenListOnboarding, + ) + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_2) + }) + + // Added to prod + it('Verify that when owner is disconnected, Send button is disabled', () => { + assets.selectTokenList(assets.tokenListOptions.allTokens) + assets.showSendBtn(0) + assets.VerifySendButtonIsDisabled() + }) +}) diff --git a/apps/web/cypress/e2e/smoke/tx_history.cy.js b/apps/web/cypress/e2e/smoke/tx_history.cy.js new file mode 100644 index 000000000..e058e37fb --- /dev/null +++ b/apps/web/cypress/e2e/smoke/tx_history.cy.js @@ -0,0 +1,91 @@ +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' + +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('[SMOKE] Tx history tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + }) + + // Token receipt + it('[SMOKE] Verify summary for token receipt', () => { + createTx.verifySummaryByName( + typeReceive.summaryTitle, + typeReceive.summaryTxInfo, + [typeReceive.summaryTxInfo, typeGeneral.statusOk], + typeReceive.altImage, + ) + }) + + it('[SMOKE] Verify exapanded details for token receipt', () => { + createTx.clickOnTransactionItemByName(typeReceive.summaryTitle, typeReceive.summaryTxInfo) + createTx.verifyExpandedDetails([ + typeReceive.title, + typeReceive.receivedFrom, + typeReceive.senderAddress, + typeReceive.transactionHash, + ]) + }) + + it('[SMOKE] Verify summary for token send', () => { + createTx.verifySummaryByName( + typeSend.title, + null, + [typeSend.summaryTxInfo, typeGeneral.statusOk], + typeSend.altImage, + typeSend.altToken, + ) + }) + + it('[SMOKE] Verify summary for on-chain rejection', () => { + createTx.verifySummaryByName( + typeOnchainRejection.title, + null, + [typeGeneral.statusOk], + typeOnchainRejection.altImage, + ) + }) + + it('[SMOKE] Verify summary for batch', () => { + createTx.verifySummaryByName(typeBatch.title, typeBatch.summaryTxInfo, [ + typeBatch.summaryTxInfo, + typeGeneral.statusOk, + ]) + }) + + it('[SMOKE] Verify summary for allowance deletion', () => { + createTx.verifySummaryByName( + typeDeleteAllowance.title, + typeDeleteAllowance.summaryTxInfo, + [typeDeleteAllowance.summaryTxInfo, typeGeneral.statusOk], + typeDeleteAllowance.altImage, + ) + }) + + it('[SMOKE] 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/cypress/e2e/smoke/tx_history_filter.cy.js b/apps/web/cypress/e2e/smoke/tx_history_filter.cy.js similarity index 100% rename from cypress/e2e/smoke/tx_history_filter.cy.js rename to apps/web/cypress/e2e/smoke/tx_history_filter.cy.js diff --git a/cypress/fixtures/address_book_addedsafes.csv b/apps/web/cypress/fixtures/address_book_addedsafes.csv similarity index 100% rename from cypress/fixtures/address_book_addedsafes.csv rename to apps/web/cypress/fixtures/address_book_addedsafes.csv diff --git a/cypress/fixtures/address_book_duplicated.csv b/apps/web/cypress/fixtures/address_book_duplicated.csv similarity index 100% rename from cypress/fixtures/address_book_duplicated.csv rename to apps/web/cypress/fixtures/address_book_duplicated.csv diff --git a/cypress/fixtures/address_book_empty_test.csv b/apps/web/cypress/fixtures/address_book_empty_test.csv similarity index 100% rename from cypress/fixtures/address_book_empty_test.csv rename to apps/web/cypress/fixtures/address_book_empty_test.csv diff --git a/cypress/fixtures/address_book_networks.csv b/apps/web/cypress/fixtures/address_book_networks.csv similarity index 100% rename from cypress/fixtures/address_book_networks.csv rename to apps/web/cypress/fixtures/address_book_networks.csv diff --git a/cypress/fixtures/address_book_test.csv b/apps/web/cypress/fixtures/address_book_test.csv similarity index 100% rename from cypress/fixtures/address_book_test.csv rename to apps/web/cypress/fixtures/address_book_test.csv diff --git a/cypress/fixtures/balances.json b/apps/web/cypress/fixtures/balances.json similarity index 100% rename from cypress/fixtures/balances.json rename to apps/web/cypress/fixtures/balances.json diff --git a/cypress/fixtures/data_import.json b/apps/web/cypress/fixtures/data_import.json similarity index 100% rename from cypress/fixtures/data_import.json rename to apps/web/cypress/fixtures/data_import.json diff --git a/cypress/fixtures/invalid_image_QR_test.png b/apps/web/cypress/fixtures/invalid_image_QR_test.png similarity index 100% rename from cypress/fixtures/invalid_image_QR_test.png rename to apps/web/cypress/fixtures/invalid_image_QR_test.png diff --git a/cypress/fixtures/safe-app.html b/apps/web/cypress/fixtures/safe-app.html similarity index 100% rename from cypress/fixtures/safe-app.html rename to apps/web/cypress/fixtures/safe-app.html diff --git a/cypress/fixtures/safes/funds.json b/apps/web/cypress/fixtures/safes/funds.json similarity index 85% rename from cypress/fixtures/safes/funds.json rename to apps/web/cypress/fixtures/safes/funds.json index 66cda0772..2d51a4d13 100644 --- a/cypress/fixtures/safes/funds.json +++ b/apps/web/cypress/fixtures/safes/funds.json @@ -11,5 +11,7 @@ "SEP_FUNDS_SAFE_10": "sep:0xE72d4D7E87672c14Df3d449C6b79f20151c18fC1", "SEP_FUNDS_SAFE_11": "sep:0x74D5228112a9652a9825a6A285Fb39e290269172", "SEP_FUNDS_SAFE_12": "sep:0xe5DC58EfDA6ebe93014AaE7A5a673C5F80118171", - "ETH_FUNDS_SAFE_13": "eth:0x8675B754342754A30A2AeF474D114d8460bca19b" + "ETH_FUNDS_SAFE_13": "eth:0x8675B754342754A30A2AeF474D114d8460bca19b", + "SEP_FUNDS_SAFE_14": "sep:0xF9e21491A1FccD40c9B658b8cA5e25018BA9105b", + "SEP_FUNDS_SAFE_15": "sep:0x1b412E4E47e3199c96d4544FD15875eA6886D4F0" } diff --git a/cypress/fixtures/safes/nfts.json b/apps/web/cypress/fixtures/safes/nfts.json similarity index 100% rename from cypress/fixtures/safes/nfts.json rename to apps/web/cypress/fixtures/safes/nfts.json diff --git a/apps/web/cypress/fixtures/safes/recovery.json b/apps/web/cypress/fixtures/safes/recovery.json new file mode 100644 index 000000000..b3bee0be7 --- /dev/null +++ b/apps/web/cypress/fixtures/safes/recovery.json @@ -0,0 +1,7 @@ +{ + "SEP_RECOVERY_SAFE_1": "sep:0x702E067A0015F1b835d9c631Cb28A9F617314F27", + "SEP_RECOVERY_SAFE_2": "sep:0xb791302040DB5Ab4Ade0b5295cecCaeF07AF07a1", + "SEP_RECOVERY_SAFE_3": "sep:0xAE1E3f93fda95eEbb857Ee06325f6F1e45EF3CBE", + "SEP_RECOVERY_SAFE_4": "sep:0xe41D568F5040FD9adeE8B64200c6B7C363C68c41", + "SEP_RECOVERY_SAFE_5": "sep:0xd366dc7Edf036eDeB7C69c808DE18480a4bAbB82" +} diff --git a/cypress/fixtures/safes/safeapps.json b/apps/web/cypress/fixtures/safes/safeapps.json similarity index 100% rename from cypress/fixtures/safes/safeapps.json rename to apps/web/cypress/fixtures/safes/safeapps.json diff --git a/apps/web/cypress/fixtures/safes/static.json b/apps/web/cypress/fixtures/safes/static.json new file mode 100644 index 000000000..f0face4b3 --- /dev/null +++ b/apps/web/cypress/fixtures/safes/static.json @@ -0,0 +1,38 @@ +{ + "SEP_STATIC_SAFE_0": "sep:0x926186108f74dB20BFeb2b6c888E523C78cb7E00", + "SEP_STATIC_SAFE_1": "sep:0x03042B890b99552b60A073F808100517fb148F60", + "SEP_STATIC_SAFE_2": "sep:0xBd69b0a9DC90eB6F9bAc3E4a5875f437348b6415", + "SEP_STATIC_SAFE_3": "sep:0x33C4AA5729D91FfB3B87AEf8a324bb6304Fb905c", + "SEP_STATIC_SAFE_4": "sep:0xBb26E3717172d5000F87DeFd391994f789D80aEB", + "SEP_STATIC_SAFE_5": "sep:0x6E834E9D04ad6b26e1525dE1a37BFd9b215f40B7", + "SEP_STATIC_SAFE_6": "sep:0xBf30F749FC027a5d79c4710D988F0D3C8e217A4F", + "SEP_STATIC_SAFE_7": "sep:0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "SEP_STATIC_SAFE_8": "sep:0x9190cc22D592dDcf396Fa616ce84a9978fD96Fc9", + "SEP_STATIC_SAFE_9": "sep:0x98705770aF3b18db0a64597F6d4DCe825915fec0", + "SEP_STATIC_SAFE_9_SHORT": "0x9870...fec0", + "SEP_STATIC_SAFE_10": "sep:0xc2F3645bfd395516d1a18CA6ad9298299d328C01", + "SEP_STATIC_SAFE_11": "sep:0x10B45a24640E2170B6AA63ea3A289D723a0C9cba", + "SEP_STATIC_SAFE_12": "sep:0xFFfaC243A24EecE6553f0Da278322aCF1Fb6CeF1", + "SEP_STATIC_SAFE_13": "sep:0x027bBe128174F0e5e5d22ECe9623698E01cd3970", + "SEP_STATIC_SAFE_14": "sep:0xe41D568F5040FD9adeE8B64200c6B7C363C68c41", + "ETH_STATIC_SAFE_15": "eth:0xfF501B324DC6d78dC9F983f140B9211c3EdB4dc7", + "GNO_STATIC_SAFE_16": "gno:0xB8d760a90a5ed54D3c2b3EFC231277e99188642A", + "MATIC_STATIC_SAFE_17": "matic:0x6D04edC44F7C88faa670683036edC2F6FC10b86e", + "BNB_STATIC_SAFE_18": "bnb:0x1D28a316431bAFf410Fe53398c6C5BD566032Eec", + "AURORA_STATIC_SAFE_19": "aurora:0xCEA454dD3d76Da856E72C3CBaDa8ee6A789aD167", + "AVAX_STATIC_SAFE_20": "avax:0x480e5A3E90a3fF4a16AECCB5d638fAba96a15c28", + "LINEA_STATIC_SAFE_21": "linea:0x95934e67299E0B3DD277907acABB512802f3536E", + "ZKSYNC_STATIC_SAFE_22": "zksync:0x49136c0270c5682FFbb38Cb29Ecf0563b2E1F9f6", + "SEP_STATIC_SAFE_23": "sep:0x589d862CE2d519d5A862066bB923da0564c3D2EA", + "SEP_STATIC_SAFE_24": "sep:0x49DC5764961DA4864DC5469f16BC68a0F765f2F2", + "SEP_STATIC_SAFE_25": "sep:0x4ECFAa2E8cb4697bCD27bdC9Ce3E16f03F73124F", + "SEP_STATIC_SAFE_26": "sep:0x755428b02A458eD17fa93c86F6C3a2046F2c4C3C", + "SEP_STATIC_SAFE_27": "sep:0xC97FCf0B8890a5a7b1a1490d44Dc9EbE3cE04884", + "MATIC_STATIC_SAFE_28": "matic:0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B", + "ZKSYNC_STATIC_SAFE_29": "zksync:0x950e07c80d7Bb754CcD84afE2b7751dc7Fd65D1f", + "SEP_STATIC_SAFE_30": "sep:0x2687E6643E189c1245EA8419e5e427809136021F", + "SEP_STATIC_SAFE_31": "sep:0x09725D3c2f9bE905F8f9f1b11a771122cf9C9f35", + "SEP_STATIC_SAFE_32": "sep:0x698C8D95D7B6b0B50338c2885d9583737546768f", + "SEP_STATIC_SAFE_33": "sep:0x597D644b1F2b66B84F2C56f0D40D0314E8D5895b", + "SEP_STATIC_SAFE_34": "sep:0xD8b85a669413b25a8BE7D7698f88b7bFA20889d2" +} diff --git a/cypress/fixtures/sepolia_test_safe_QR.png b/apps/web/cypress/fixtures/sepolia_test_safe_QR.png similarity index 100% rename from cypress/fixtures/sepolia_test_safe_QR.png rename to apps/web/cypress/fixtures/sepolia_test_safe_QR.png diff --git a/apps/web/cypress/fixtures/staking_data.json b/apps/web/cypress/fixtures/staking_data.json new file mode 100644 index 000000000..b424f7016 --- /dev/null +++ b/apps/web/cypress/fixtures/staking_data.json @@ -0,0 +1,18 @@ +{ + "type": { + "history": { + "ETH_3205184": "32.05184 ETH", + "ETH32_2": "32 ETH", + "ETH_32": "ETH32", + "received": "Receive", + "call_batchWithdrawCLFee": "batchWithdrawCLFee", + "call_requestValidatorsExit": "requestValidatorsExit", + "StakingContract": "StakingContract", + "claim": "Claim", + "stake": "Stake", + "withdrawal": "Withdraw request", + "validator_1": "1 Validator", + "rewardsValue": "Approx. every 5 days after activation" + } + } +} diff --git a/apps/web/cypress/fixtures/swaps_data.json b/apps/web/cypress/fixtures/swaps_data.json new file mode 100644 index 000000000..2ec3037bf --- /dev/null +++ b/apps/web/cypress/fixtures/swaps_data.json @@ -0,0 +1,77 @@ +{ + "type": { + "queue": { + "contractName": "GPv2Settlement", + "action": "setPreSignature", + "oneOfOne": "1 out of 1", + "oneOfTwo": "1 out of 2", + "title": "Swap order" + }, + "orderDetails": { + "expiry2Mins": "in 2 minutes", + "expiry5Mins": "in 5 minutes", + "expiry12Months": "in 12 months", + "interactWith": "GPv2Settlement", + "DAIeqCOW": "1 DAI = COW" + }, + "history": { + "buyOrder": "Buy order", + "buy": "Buy", + "oneGNO": "1 GNO", + "oneGNOFull": "1 GNO = 8.4747 COW", + "forAtMost": "For at most", + "forAtMostCow": "For at most COW", + "cow": "COW", + "expired": "Expired", + "executionNeeded": "Execution needed", + "cancelled": "Cancelled", + "actionApprove": "CoW Protocol Token: approve", + "actionPreSignature": "GPv2Settlement: setPreSignature", + "actionApproveEth": "Wrapped Ether: approve", + "actionDepositEth": "Wrapped Ether: deposit", + "sellOrder": "Sell order", + "actionApproveG": "approve", + "actionPreSignatureG": "setPreSignature", + "composableCoW": "ComposableCoW", + "actionDepositG": "deposit", + "amount": "Amount", + "executionPrice": "Execution price", + "limitPrice": "Limit price", + "surplus": "Surplus", + "expiry": "Expiry", + "oderId": "Order ID", + "status": "Status", + "sellFull": "Sell 1 COW", + "sell10Cow": "Sell 10 COW", + "sell": "Sell", + "oneCOW": "1 COW", + "forAtLeast": "for at least", + "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", + "DAIeqCOW": "1 DAI = COW", + "UNIeqCOW": "1 UNI = K COW", + "DAIeqWETH": "1 DAI = WETH", + "USDTeqUSDC": "1 USDT = 0.19342 USDC", + "dai": "DAI", + "filled": "Filled", + "partiallyFilled": "Partially filled", + "gGpV2": "GPv2Settlement", + "createWithContext": "createWithContext", + "safeAppTitile": "CowSwap", + "slippage": "Slippage", + "widget_fee": "Widget fee", + "interactWith": "Interact with", + "title": "Swap order", + "limitorder_title": "Limit order", + "twaporder_title": "TWAP order", + "multiSend": "multiSend", + "multiSendCallOnly1_3_0": "Safe: MultiSendCallOnly 1.3.0", + "multiSendCallOnly1_4_1": "Safe: MultiSendCallOnly 1.4.1", + "altImage": "Swap order" + } + } +} diff --git a/cypress/fixtures/test-empty-batch.json b/apps/web/cypress/fixtures/test-empty-batch.json similarity index 100% rename from cypress/fixtures/test-empty-batch.json rename to apps/web/cypress/fixtures/test-empty-batch.json diff --git a/cypress/fixtures/test-invalid-batch.json b/apps/web/cypress/fixtures/test-invalid-batch.json similarity index 100% rename from cypress/fixtures/test-invalid-batch.json rename to apps/web/cypress/fixtures/test-invalid-batch.json diff --git a/cypress/fixtures/test-mainnet-batch.json b/apps/web/cypress/fixtures/test-mainnet-batch.json similarity index 100% rename from cypress/fixtures/test-mainnet-batch.json rename to apps/web/cypress/fixtures/test-mainnet-batch.json diff --git a/cypress/fixtures/test-modified-batch.json b/apps/web/cypress/fixtures/test-modified-batch.json similarity index 100% rename from cypress/fixtures/test-modified-batch.json rename to apps/web/cypress/fixtures/test-modified-batch.json diff --git a/apps/web/cypress/fixtures/test-working-batch.json b/apps/web/cypress/fixtures/test-working-batch.json new file mode 100644 index 000000000..309e5af6e --- /dev/null +++ b/apps/web/cypress/fixtures/test-working-batch.json @@ -0,0 +1,53 @@ +{ + "version": "1.0", + "chainId": "11155111", + "createdAt": 1702396600164, + "meta": { + "name": "Transactions Batch", + "description": "", + "txBuilderVersion": "1.16.4", + "createdFromSafeAddress": "0x4DD4cB2299E491E1B469245DB589ccB2B16d7bde", + "createdFromOwnerAddress": "", + "checksum": "0x5117b795c64424440e9fccaac343a93606b405852a4b28809c909f9c805839f5" + }, + "transactions": [ + { + "to": "0x11AB70A4564C62F567B92868Cb5e69b50c5434aF", + "value": "0", + "data": null, + "contractMethod": { + "inputs": [ + { + "internalType": "address", + "name": "newValue", + "type": "address" + } + ], + "name": "testAddressValue", + "payable": false + }, + "contractInputsValues": { + "newValue": "0x4DD4cB2299E491E1B469245DB589ccB2B16d7bde" + } + }, + { + "to": "0x11AB70A4564C62F567B92868Cb5e69b50c5434aF", + "value": "0", + "data": null, + "contractMethod": { + "inputs": [ + { + "internalType": "address", + "name": "newValue", + "type": "address" + } + ], + "name": "testAddressValue", + "payable": false + }, + "contractInputsValues": { + "newValue": "0xc6b82bA149CFA113f8f48d5E3b1F78e933e16DfD" + } + } + ] +} diff --git a/cypress/fixtures/txhistory_data_data.json b/apps/web/cypress/fixtures/txhistory_data_data.json similarity index 76% rename from cypress/fixtures/txhistory_data_data.json rename to apps/web/cypress/fixtures/txhistory_data_data.json index 8a276ccf8..9bdab8a0e 100644 --- a/cypress/fixtures/txhistory_data_data.json +++ b/apps/web/cypress/fixtures/txhistory_data_data.json @@ -10,7 +10,7 @@ "executedBy": "Executed" }, "accountCreation": { - "actionsSummary": "Safe Account created by 0xC16D...6fED", + "actionsSummary": "Created by 0xC16D...6fED", "transactionSafehash": {}, "summaryTime": "10:30 AM", "title": "Safe Account created", @@ -34,14 +34,33 @@ "receive": { "title": "Receive", "summaryTitle": "Received", + "summaryTxInfoDAI": "25.50103 DAI", + "summaryTxInfoETH35": "ETH35.com", + "summaryTxInfoETH": "0.018 ETH", + "summaryTxInfoETH_2": "1 ETH", + "summaryTxInfoNFT": "1 FLOWER #6188", "summaryTxInfo": "1,000 QTRUST", "summaryTime": "11:00 AM", "receivedFrom": "Received 1,000 QTRUST from", "senderAddress": "sep:0x96D4c6fFC338912322813a77655fCC926b9A5aC5", + "senderAddressEth": "eth:0x5c4378Be60a8Af15d7d1Eeb61Dbc637aD56f2D23", "transactionHash": "0xd89d...9136", "transactionHashCopied": "0x415977f4e4912e22a5cabc4116f7e8f8984996e00a641dcccf8cbe1eb3db3e7d", "altImage": "Received", - "altToken": "ETH" + "altImageDAI": "DAI", + "altToken": "ETH", + "altTokenNFT": "FLOWER #6188", + "altTokenETH35": "$ ETH35.com", + "GPv2Settlement": "GPv2Settlement", + "GPv2SettlementAddress": "eth:0x9008D19f58AAbD9eD0D60971565", + "Proxy": "Proxy", + "ProxyAddress": "eth:0xeF6d82f75E0429fC9261583108542B87089CC47B", + "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" }, "send": { "title": "Sent", @@ -51,7 +70,9 @@ "recipientAddress": "sep:0x06373d5e45AD31BD354CeBfA8dB4eD2c75B8708e", "transactionHash": "0x6a59...6a98", "altImage": "Sent", - "altToken": "ETH" + "altToken": "ETH", + "txBuilderTitle": "Transaction Builder", + "txBuilderAltImage": "Transaction Builder" }, "onchainRejection": { "title": "On-chain rejection", @@ -62,11 +83,11 @@ "safeTxHash": "0x1303...0fe2" }, "batchNativeTransfer": { - "title": "Safe: MultiSendCallOnly 1.3.0", + "title": "Batch", "summaryTxInfo": "2 actions", "summaryTime": "11:24 AM", - "description": "MultiSend contract", - "altImage": "Safe: MultiSendCallOnly 1.3.0", + "description": "Batch transaction with 2 actions", + "altImage": "Batch", "contractTitle": "Safe: MultiSendCallOnly 1.3.0", "contractAddress": "sep:0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", "transactionHash": "0xa5dd...b064", @@ -82,9 +103,9 @@ "description": "Add signer", "altImage": "addOwnerWithThreshold", "requiredConfirmationsTitle": "Required confirmations for new transactions", - "ownerAddress": "sep:0x01A9F68e339da12565cfBc47fe7D6EdEcB11C46f", - "transactionHash": "0x51d5...da62", - "safeTxHash": "0xdcc5...e1b2" + "ownerAddress": "sep:0x4fe7164d7cA511Ab35520bb14065F1693240dC90", + "transactionHash": "0xfcad...ab35", + "safeTxHash": "0x8583...3d2d" }, "removeOwner": { "title": "removeOwner", @@ -145,28 +166,29 @@ "baseGas": "baseGas" }, "spendingLimits": { - "title": "Safe: MultiSendCallOnly 1.3.0", + "title": "Batch", "summaryTxInfo": "3 actions", "summaryTime": "11:06 AM", - "description": "MultiSend contract", - "altImage": "Safe: MultiSendCallOnly 1.3.0", + "description": "Batch transaction with 3 actions", + "altImage": "Batch", "contractTitle": "Safe: MultiSendCallOnly 1.3.0", "contractAddress": "sep:0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", "transactionHash": "0x69c3...bc37", "safeTxHash": "0xf81c...243e", + "call_multiSend": "CallmultiSend", "enableModule": { "title": "enableModule", "description": "Interact with", "interactionAddress": "sep:0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", "moduleAddress": "sep:0xCFbF...3134", - "moduleAddressTitle": "module(address)" + "moduleAddressTitle": "module address" }, "addDelegate": { "title": "addDelegate", "description": "Interact with", "interactionAddress": "sep:0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", "delegateAddress": "sep:0xC16D...6fED", - "delegateAddressTitle": "delegate(address)" + "delegateAddressTitle": "delegate address" }, "setAllowance": { "title": "setAllowance", @@ -175,7 +197,7 @@ "allowanceAmount": "100000000000", "resetTimeMin": "0", "resetBaseMin": "0", - "delegateAddressTitle": "delegate(address)" + "delegateAddressTitle": "delegate address" } }, "untrustedReceivedToken": { @@ -188,6 +210,18 @@ "transactionHashCopied": "0x54e7e766b08d4210bc1cfcc84d84ca4782a0cc1efe9e7d9c032d305060ed2a7c", "altImage": "Received", "altToken": "" + }, + "bulkTransaction": { + "send": "Sent", + "receive": "Received", + "twoTx": "2 transactions", + "threeTx": "3 transactions", + "wrappedEther": "Wrapped Ether", + "addOwnerWithThreshold": "addOwnerWithThreshold", + "1transfer": "1transfer", + "2removeOwner": "2removeOwner", + "COW": "-10 COW", + "DAI": "363.19846 DAI" } } } diff --git a/apps/web/cypress/fixtures/txhistory_incoming_data.json b/apps/web/cypress/fixtures/txhistory_incoming_data.json new file mode 100644 index 000000000..d1836018e --- /dev/null +++ b/apps/web/cypress/fixtures/txhistory_incoming_data.json @@ -0,0 +1,154 @@ +{ + "results": [ + { + "type": "TRANSACTION", + "transaction": { + "id": "transfer_0x8675B754342754A30A2AeF474D114d8460bca19b_e715646c00d8c513b16de213dbdcfea16f58aa1294306fdd5866a4d1fab643e4794", + "timestamp": 1698633431000, + "txStatus": "SUCCESS", + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + "name": "GPv2Settlement", + "logoUri": null + }, + "recipient": { + "value": "0x8675B754342754A30A2AeF474D114d8460bca19b", + "name": "Proxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { + "type": "ERC20", + "tokenAddress": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "value": "25501028934092566738", + "tokenName": "Dai Stablecoin", + "tokenSymbol": "DAI", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0x6B175474E89094C44Da98b954EedeAC495271d0F.png", + "decimals": 18, + "trusted": true, + "imitation": false + } + }, + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x715646c00d8c513b16de213dbdcfea16f58aa1294306fdd5866a4d1fab643e47" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "id": "transfer_0x8675B754342754A30A2AeF474D114d8460bca19b_e3873b1a1310fd4acd00249456b9700ea7fbe1e61261c3efd08a288abf8756d0b138", + "timestamp": 1695869291000, + "txStatus": "SUCCESS", + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0xeF6d82f75E0429fC9261583108542B87089CC47B", + "name": "Proxy", + "logoUri": null + }, + "recipient": { + "value": "0x8675B754342754A30A2AeF474D114d8460bca19b", + "name": "Proxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { + "type": "ERC721", + "tokenAddress": "0x4F41d10F7E67fD16bDe916b4A6DC3Dd101C57394", + "tokenId": "6188", + "tokenName": "Flower", + "tokenSymbol": "FLOWER", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0x4F41d10F7E67fD16bDe916b4A6DC3Dd101C57394.png", + "trusted": false + } + }, + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x3873b1a1310fd4acd00249456b9700ea7fbe1e61261c3efd08a288abf8756d0b" + }, + "conflictType": "None" + }, + + { + "type": "TRANSACTION", + "transaction": { + "id": "transfer_0x8675B754342754A30A2AeF474D114d8460bca19b_e698326275adfeeb7bfbf0fbb05a547c569934392caaa69ad6c1ebb1d960cca8f497", + "timestamp": 1725733523000, + "txStatus": "SUCCESS", + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "name": "Wrapped Ether", + "logoUri": "https://assets.coingecko.com/coins/images/2518/thumb/weth.png?1696503332" + }, + "recipient": { + "value": "0x8675B754342754A30A2AeF474D114d8460bca19b", + "name": "Proxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { + "type": "ERC20", + "tokenAddress": "0xC6d3D201530a6D4aD9dFbAAd39C5f68A9A470a69", + "value": "9283", + "tokenName": "$ ETH35.com - Visit to claim bonus rewards", + "tokenSymbol": "$ ETH35.com", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0xC6d3D201530a6D4aD9dFbAAd39C5f68A9A470a69.png", + "decimals": 0, + "trusted": false, + "imitation": false + } + }, + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x698326275adfeeb7bfbf0fbb05a547c569934392caaa69ad6c1ebb1d960cca8f" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "id": "multisig_0x8675B754342754A30A2AeF474D114d8460bca19b_0x36248faa2973e04d2d2a78916d417ff32e4e558b587923268f6c4f928d30b730", + "timestamp": 1722806183000, + "txStatus": "SUCCESS", + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0x8675B754342754A30A2AeF474D114d8460bca19b", + "name": null, + "logoUri": null + }, + "recipient": { + "value": "0xF616fA313Cc2E9C517DFe87D80ab280d16aBbc26", + "name": null, + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { + "type": "NATIVE_COIN", + "value": "18000000000000000" + } + }, + "executionInfo": { + "type": "MULTISIG", + "nonce": 378, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x91b501fe122ae0b76e53b09b8f033f2520109ceb85c177d20b3030fb1902bebe" + }, + "conflictType": "None" + } + ] +} diff --git a/cypress/fixtures/txmessages_data.json b/apps/web/cypress/fixtures/txmessages_data.json similarity index 97% rename from cypress/fixtures/txmessages_data.json rename to apps/web/cypress/fixtures/txmessages_data.json index fdd609947..71816eddf 100644 --- a/cypress/fixtures/txmessages_data.json +++ b/apps/web/cypress/fixtures/txmessages_data.json @@ -3,6 +3,7 @@ "general": { "confirmed": "Confirmed", "sign": "Sign", + "zeroOftwo": "0 out of 2", "oneOftwo": "1 out of 2", "twoOftwo": "2 out of 2" }, diff --git a/cypress/plugins/index.js b/apps/web/cypress/plugins/index.js similarity index 100% rename from cypress/plugins/index.js rename to apps/web/cypress/plugins/index.js diff --git a/apps/web/cypress/snapshots/actual/cypress/e2e/regression/tx_decoding.cy.js/tx_decoding.png b/apps/web/cypress/snapshots/actual/cypress/e2e/regression/tx_decoding.cy.js/tx_decoding.png new file mode 100644 index 000000000..08188e23a Binary files /dev/null and b/apps/web/cypress/snapshots/actual/cypress/e2e/regression/tx_decoding.cy.js/tx_decoding.png differ diff --git a/cypress/support/api/contracts.js b/apps/web/cypress/support/api/contracts.js similarity index 100% rename from cypress/support/api/contracts.js rename to apps/web/cypress/support/api/contracts.js diff --git a/apps/web/cypress/support/api/utils_ether.js b/apps/web/cypress/support/api/utils_ether.js new file mode 100644 index 000000000..367b675fb --- /dev/null +++ b/apps/web/cypress/support/api/utils_ether.js @@ -0,0 +1,5 @@ +import { ethers } from 'ethers' + +export function createSigners(privateKeys, provider) { + return privateKeys.map((privateKey) => new ethers.Wallet(privateKey, provider)) +} diff --git a/apps/web/cypress/support/api/utils_protocolkit.js b/apps/web/cypress/support/api/utils_protocolkit.js new file mode 100644 index 000000000..61b56e5ab --- /dev/null +++ b/apps/web/cypress/support/api/utils_protocolkit.js @@ -0,0 +1,16 @@ +import Safe from '@safe-global/protocol-kit' + +export async function createSafes(safeConfigurations) { + const safes = [] + for (const config of safeConfigurations) { + const providerUrl = config.provider._getConnection().url + + const safe = await Safe.init({ + provider: providerUrl, + signer: config.signer, + safeAddress: config.safeAddress, + }) + safes.push(safe) + } + return safes +} diff --git a/cypress/support/commands.js b/apps/web/cypress/support/commands.js similarity index 100% rename from cypress/support/commands.js rename to apps/web/cypress/support/commands.js diff --git a/cypress/support/constants.js b/apps/web/cypress/support/constants.js similarity index 91% rename from cypress/support/constants.js rename to apps/web/cypress/support/constants.js index 0bba9e43a..d1e643809 100644 --- a/cypress/support/constants.js +++ b/apps/web/cypress/support/constants.js @@ -37,6 +37,7 @@ export const goerlySafeName = /g(ö|oe)rli-safe/ export const sepoliaSafeName = 'sepolia-safe' export const goerliToken = /G(ö|oe)rli Ether/ +export const prodbaseUrl = 'https://app.safe.global' export const swapWidget = 'https://swap.cow.fi/#/11155111/widget/swap/' export const safeTestAppurl = 'https://safe-apps-test-app.pages.dev' export const TX_Builder_url = 'https://safe-apps.dev.5afe.dev/tx-builder' @@ -45,12 +46,14 @@ 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 stakingUrl = '/stake?safe=' export const appsCustomUrl = 'apps/custom?safe=' export const BALANCE_URL = '/balances?safe=' export const balanceNftsUrl = '/balances/nfts?safe=' export const transactionQueueUrl = '/transactions/queue?safe=' export const transactionsHistoryUrl = '/transactions/history?safe=' export const transactionsMessagesUrl = '/transactions/messages?safe=' +export const transactionsQueued = 'transactions/queued' export const transactionUrl = '/transactions/tx?safe=' export const openAppsUrl = '/apps/open?safe=' export const homeUrl = '/home?safe=' @@ -65,11 +68,12 @@ export const appSettingsUrl = '/settings/safe-apps' export const setupUrl = '/settings/setup?safe=' 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 sepoliaEtherscanlLink = 'https://sepolia.etherscan.io/address' +export const etherscanlLink = 'etherscan.io' export const stagingTxServiceUrl = 'https://safe-transaction-sepolia.staging.5afe.dev/api' export const stagingTxServiceSafesUrl = '/safes/' export const stagingTxServiceBalancesUrl = '/balances/' @@ -81,6 +85,7 @@ export const stagingCGWChains = '/chains/' export const stagingCGWSafes = '/safes/' export const stagingCGWNone = '/nonces/' export const stagingCGWCollectibles = '/collectibles/' +export const stagingCGWDelegatesUrl = '/delegates?safe=' export const relayPath = '/relay/' export const stagingCGWAllTokensBalances = '/balances/USD?trusted=false&exclude_spam=false' @@ -92,8 +97,12 @@ export const safeListEndpoint = '**/safes' export const VALID_QR_CODE_PATH = '../fixtures/sepolia_test_safe_QR.png' export const INVALID_QR_CODE_PATH = '../fixtures/invalid_image_QR_test.png' +export const safeContractVersions = { + v1_4_1_L2: '1.4.1+L2', +} + export const commonThresholds = { - oneOfOne: '1 out of 1 signer(s)', + oneOfOne: '1 out of 1 signer', } export const TXActionNames = { resetAllowance: 'resetAllowance', @@ -102,6 +111,7 @@ export const TXActionNames = { export const networkKeys = { sepolia: '11155111', + polygon: '137', } export const mainSideMenuOptions = { home: 'Home', @@ -122,6 +132,10 @@ export const networks = { sepolia: 'Sepolia', polygon: 'Polygon', gnosis: 'Gnosis', + zkSync: 'zkSync Era', + base: 'Base', + optimism: 'Optimism', + gnosisChiado: 'Gnosis Chiado', } export const tokenAbbreviation = { @@ -196,6 +210,7 @@ export const addressBookErrrMsg = { emptyAddress: 'Owner', safeAlreadyAdded: 'Safe Account is already added', prefixMismatch: "doesn't match the current chain", + ownSafeGuardian: 'The Safe Account cannot be a Recoverer of itself', invalidPrefix(prefix) { return `"${prefix}" doesn't match the current chain` }, @@ -225,16 +240,20 @@ export const addresBookContacts = { }, } +export const CURRENT_COOKIE_TERMS_VERSION = Cypress.env('CURRENT_COOKIE_TERMS_VERSION') + export const localStorageKeys = { SAFE_v2__addressBook: 'SAFE_v2__addressBook', SAFE_v2__batch: 'SAFE_v2__batch', SAFE_v2__settings: 'SAFE_v2__settings', SAFE_v2__addedSafes: 'SAFE_v2__addedSafes', SAFE_v2__safeApps: 'SAFE_v2__safeApps', - SAFE_v2__cookies: 'SAFE_v2__cookies', + SAFE_v2_cookies: 'SAFE_v2__cookies_terms', SAFE_v2__tokenlist_onboarding: 'SAFE_v2__tokenlist_onboarding', SAFE_v2__customSafeApps_11155111: 'SAFE_v2__customSafeApps-11155111', SAFE_v2__SafeApps__browserPermissions: 'SAFE_v2__SafeApps__browserPermissions', SAFE_v2__SafeApps__infoModal: 'SAFE_v2__SafeApps__infoModal', SAFE_v2__undeployedSafes: 'SAFE_v2__undeployedSafes', + SAFE_v2__batch: 'SAFE_v2__batch', + SAFE_v2__visitedSafes: 'SAFE_v2__visitedSafes', } diff --git a/apps/web/cypress/support/e2e.js b/apps/web/cypress/support/e2e.js new file mode 100644 index 000000000..49a4bbe54 --- /dev/null +++ b/apps/web/cypress/support/e2e.js @@ -0,0 +1,91 @@ +// *********************************************************** +// This example support/e2e.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import '@testing-library/cypress/add-commands' +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') + +/* + FIXME: The terms banner is being displayed depending on the cookie banner local storage state. + However, in cypress the cookie banner state is evaluated after the banner has been dismissed not before + which displays the terms banner even though it shouldn't so we need to globally hide it in our tests. + */ +const { addCompareSnapshotCommand } = require('cypress-visual-regression/dist/command') +addCompareSnapshotCommand() + +const beamer = JSON.parse(Cypress.env('BEAMER_DATA_E2E') || '{}') +const productID = beamer.PRODUCT_ID + +Cypress.on('test:before:run', () => { + Cypress.automation('remote:debugger:protocol', { + command: 'Emulation.setLocaleOverride', + params: { + locale: 'en-US', + }, + }) +}) + +before(() => { + Cypress.on('uncaught:exception', (err, runnable) => { + return false + }) + cy.on('log:added', (ev) => { + if (Cypress.config('hideXHR')) { + const app = window.top + if (app && !app.document.head.querySelector('[data-hide-command-log-request]')) { + const style = app.document.createElement('style') + style.innerHTML = '.command-name-request, .command-name-xhr { display: none }' + style.setAttribute('data-hide-command-log-request', '') + app.document.head.appendChild(style) + } + } + const originalConsoleLog = console.log + console.log = (...args) => { + if (typeof args[0] === 'string' && !args[0].includes('Intercepted request with headers')) { + originalConsoleLog(...args) + } + } + }) +}) + +beforeEach(() => { + cy.setupInterceptors() + cy.clearAllSessionStorage() + cy.clearLocalStorage() + cy.clearCookies() + cy.window().then((window) => { + const getDate = () => new Date().toISOString() + const beamerKey1 = `_BEAMER_FIRST_VISIT_${productID}` + const beamerKey2 = `_BEAMER_BOOSTED_ANNOUNCEMENT_DATE_${productID}` + const cookiesKey = 'SAFE_v2__cookies_terms' + window.localStorage.setItem(beamerKey1, getDate()) + window.localStorage.setItem(beamerKey2, getDate()) + window.localStorage.setItem(cookiesKey, ls.cookies.acceptedCookies) + window.localStorage.setItem( + constants.localStorageKeys.SAFE_v2__SafeApps__infoModal, + ls.appPermissions(constants.safeTestAppurl).infoModalAccepted, + ) + cy.wrap(window.localStorage).invoke('getItem', cookiesKey).should('equal', ls.cookies.acceptedCookies) + }) + cy.visit(constants.setupUrl + 'sep:0xBb26E3717172d5000F87DeFd391994f789D80aEB') + acceptCookies2() +}) diff --git a/cypress/support/localstorage_data.js b/apps/web/cypress/support/localstorage_data.js similarity index 85% rename from cypress/support/localstorage_data.js rename to apps/web/cypress/support/localstorage_data.js index 65add7561..e442bc8e3 100644 --- a/cypress/support/localstorage_data.js +++ b/apps/web/cypress/support/localstorage_data.js @@ -1,4 +1,15 @@ /* eslint-disable */ + +import { CURRENT_COOKIE_TERMS_VERSION } from './constants.js' + +const cookieState = { + necessary: true, + updates: true, + analytics: true, + terms: true, + termsVersion: CURRENT_COOKIE_TERMS_VERSION, +} + export const batchData = { entry0: { 11155111: { @@ -111,7 +122,7 @@ export const batchData = { logoUri: null, }, direction: 'OUTGOING', - transferInfo: { type: 'NATIVE_COIN', value: '1000000000000000' }, + transferInfo: { type: 'NATIVE_COIN', value: '2000000000000000' }, }, txData: { hexData: null, @@ -268,7 +279,22 @@ export const batchData = { }, }, } +export const visitedSafes = { + set1: { + 11155111: { + '0x905934aA8758c06B2422F0C90D97d2fbb6677811': { + lastVisited: 1732794651004, + }, + }, + }, +} export const addressBookData = { + proposers: { + 11155111: { '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED': 'AD Proposer1' }, + }, + addedSafesImport: { + 11155111: { '0x6d0b6F96f665Bb4490f9ddb2e450Da2f7e546dC1': 'imported-safe' }, + }, sepoliaAddress1: { 11155111: { '0x6a5602335a878ADDCa4BF63a050E34946B56B5bC': 'Owner1' }, }, @@ -329,12 +355,43 @@ export const addressBookData = { '0x9E6DAfe829431e1892EcF8461FDAd02665170c31': 'Added non-owner', }, }, + multichain: { + 137: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': 'Multichain polygon', + }, + 11155111: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': 'Multichain Sepolia', + }, + }, + undeployed: { + 11155111: { + '0x926186108f74dB20BFeb2b6c888E523C78cb7E00': 'Undeployed Sepolia', + }, + }, + undeployedSet: { + 100: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': 'Safe A', + }, + 1: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': 'Safe B', + }, + }, + undeployedEth: { + 1: { + '0x926186108f74dB20BFeb2b6c888E523C78cb7E00': 'Undeployed Sepolia', + }, + }, sortingData: { 11155111: { '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED': 'AA Safe', '0x6a5602335a878ADDCa4BF63a050E34946B56B5bC': 'BB Safe', }, }, + autofillData: { + 11155111: { + '0x01A9F68e339da12565cfBc47fe7D6EdEcB11C46f': 'David', + }, + }, sameOwnerName: { 11155111: { '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED': 'Automation owner Sepolia', @@ -374,7 +431,7 @@ export const addressBookData = { '0xc2F3645bfd395516d1a18CA6ad9298299d328C01': 'Safe 27', }, }, - cookies: { necessary: true, updates: true, analytics: true }, + cookies: cookieState, } export const safeSettings = { @@ -641,6 +698,40 @@ export const addedSafes = { }, }, }, + set5: { + 137: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': { + owners: [ + { + value: '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED', + }, + ], + threshold: 1, + }, + }, + 11155111: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': { + owners: [ + { + value: '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED', + }, + ], + threshold: 1, + }, + }, + }, + set6_undeployed_safe: { + 11155111: { + '0x926186108f74dB20BFeb2b6c888E523C78cb7E00': { + owners: [ + { + value: '0x9445deb174C1eCbbfce8d31D33F438B8e7a0F1BA', + }, + ], + threshold: 1, + }, + }, + }, } export const pinnedApps = { @@ -657,6 +748,13 @@ export const customApps = (url) => ({ }, }) +const infoModalAccepted = { + 11155111: { + consentsAccepted: true, + warningCheckedCustomApps: [], + }, +} + export const appPermissions = (url) => ({ grantedPermissions: { [url]: [ @@ -664,27 +762,73 @@ export const appPermissions = (url) => ({ { feature: 'microphone', status: 'granted' }, ], }, - infoModalAccepted: { 11155111: { consentsAccepted: true, warningCheckedCustomApps: [] } }, + infoModalAccepted: JSON.stringify(infoModalAccepted), }) export const cookies = { - acceptedCookies: { necessary: true, updates: true, analytics: true }, + acceptedCookies: JSON.stringify(cookieState), acceptedTokenListOnboarding: true, } export const undeployedSafe = { safe1: { 11155111: { - '0xe41D568F5040FD9adeE8B64200c6B7C363C68c41': { + '0x926186108f74dB20BFeb2b6c888E523C78cb7E00': { props: { safeAccountConfig: { threshold: 1, - owners: ['0xC16Db0251654C0a72E91B190d81eAD367d2C6fED'], + owners: ['0x9445deb174C1eCbbfce8d31D33F438B8e7a0F1BA'], fallbackHandler: '0x017062a1dE2FE6b99BE3d9d37841FeD19F573804', }, - safeDeploymentConfig: { saltNonce: '20', safeVersion: '1.3.0' }, + safeDeploymentConfig: { + saltNonce: '21', + safeVersion: '1.3.0', + }, + }, + status: { + status: 'AWAITING_EXECUTION', + type: 'PayLater', + }, + }, + }, + }, + safes2: { + 1: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': { + props: { + safeAccountConfig: { + threshold: 1, + owners: ['0x3ba5d9a6d6169429Adb278768D9681A125C01Af6'], + fallbackHandler: '0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99', + }, + safeDeploymentConfig: { + saltNonce: '0', + safeVersion: '1.4.1', + }, + }, + status: { + status: 'AWAITING_EXECUTION', + type: 'PayLater', + }, + }, + }, + 100: { + '0xC96ee38f5A73C8A70b565CB8EA938D2aF913ee3B': { + props: { + safeAccountConfig: { + threshold: 1, + owners: ['0x3ba5d9a6d6169429Adb278768D9681A125C01Af6'], + fallbackHandler: '0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99', + }, + safeDeploymentConfig: { + saltNonce: '0', + safeVersion: '1.4.1', + }, + }, + status: { + status: 'AWAITING_EXECUTION', + type: 'PayLater', }, - status: { status: 'AWAITING_EXECUTION' }, }, }, }, diff --git a/cypress/support/safe-apps-commands.js b/apps/web/cypress/support/safe-apps-commands.js similarity index 87% rename from cypress/support/safe-apps-commands.js rename to apps/web/cypress/support/safe-apps-commands.js index ddbf3d826..798eb50d3 100644 --- a/cypress/support/safe-apps-commands.js +++ b/apps/web/cypress/support/safe-apps-commands.js @@ -8,7 +8,7 @@ Cypress.Commands.add('visitSafeApp', (appUrl) => { window.localStorage.setItem( INFO_MODAL_KEY, JSON.stringify({ - 5: { consentsAccepted: true, warningCheckedCustomApps: allowedApps }, + 11155111: { consentsAccepted: true, warningCheckedCustomApps: allowedApps }, }), ) }) diff --git a/cypress/support/safes/safesHandler.js b/apps/web/cypress/support/safes/safesHandler.js similarity index 100% rename from cypress/support/safes/safesHandler.js rename to apps/web/cypress/support/safes/safesHandler.js diff --git a/apps/web/cypress/support/utils/checkers.js b/apps/web/cypress/support/utils/checkers.js new file mode 100644 index 000000000..492071bb6 --- /dev/null +++ b/apps/web/cypress/support/utils/checkers.js @@ -0,0 +1,9 @@ +export function startsWith0x(str) { + const pattern = /^0x/ + return pattern.test(str) +} + +export const isInRedRange = (rgbColor) => { + const [r, g, b] = rgbColor.match(/\d+/g).map(Number) + return r >= 200 && r <= 255 && g >= 0 && g <= 95 && b >= 0 && b <= 120 +} diff --git a/apps/web/cypress/support/utils/ethers.js b/apps/web/cypress/support/utils/ethers.js new file mode 100644 index 000000000..1e56b8634 --- /dev/null +++ b/apps/web/cypress/support/utils/ethers.js @@ -0,0 +1,5 @@ +import { ethers } from 'ethers' + +export const getMockAddress = () => { + return ethers.getAddress('0x1234567890abcdef1234567890abcdef12345678') +} diff --git a/apps/web/cypress/support/utils/gtag.js b/apps/web/cypress/support/utils/gtag.js new file mode 100644 index 000000000..fe6e9fd07 --- /dev/null +++ b/apps/web/cypress/support/utils/gtag.js @@ -0,0 +1,72 @@ +export function getEvents() { + cy.window().then((win) => { + cy.wrap(win.dataLayer).as('dataLayer') + }) +} + +export const checkDataLayerEvents = (expectedEvents) => { + cy.get('@dataLayer').should((dataLayer) => { + expectedEvents.forEach((expectedEvent) => { + const eventExists = dataLayer.some((event) => { + return Object.keys(expectedEvent).every((key) => { + return event[key] === expectedEvent[key] + }) + }) + expect(eventExists, `Expected event matching fields: ${JSON.stringify(expectedEvent)} not found`).to.be.true + }) + }) +} + +export const events = { + safeCreatedCF: { + category: 'create-safe', + action: 'Created Safe', + eventName: 'safe_created', + eventLabel: 'counterfactual', + eventType: 'safe_created', + }, + + txCreatedSwapOwner: { + category: 'transactions', + action: 'Create transaction', + eventName: 'tx_created', + eventLabel: 'owner_swap', + }, + + txConfirmedAddOwner: { + category: 'transactions', + action: 'Confirm transaction', + eventLabel: 'owner_add', + eventType: 'tx_confirmed', + event: 'tx_confirmed', + }, + txCreatedSwap: { + category: 'transactions', + action: 'Confirm transaction', + eventLabel: 'native_swap', + eventType: 'tx_created', + }, + + txConfirmedSwap: { + category: 'transactions', + action: 'Confirm transaction', + eventLabel: 'native_swap', + eventType: 'tx_confirmed', + }, + + txCreatedTxBuilder: { + category: 'transactions', + action: 'Confirm transaction', + eventLabel: 'https://safe-apps.dev.5afe.dev/tx-builder', + eventType: 'tx_created', + event: 'tx_created', + }, + + txConfirmedTxBuilder: { + category: 'transactions', + action: 'Confirm transaction', + eventLabel: 'https://safe-apps.dev.5afe.dev/tx-builder', + eventType: 'tx_confirmed', + event: 'tx_confirmed', + }, +} diff --git a/cypress/support/utils/txquery.js b/apps/web/cypress/support/utils/txquery.js similarity index 97% rename from cypress/support/utils/txquery.js rename to apps/web/cypress/support/utils/txquery.js index c4845de5f..844501610 100644 --- a/cypress/support/utils/txquery.js +++ b/apps/web/cypress/support/utils/txquery.js @@ -9,7 +9,7 @@ function buildQueryUrl({ chainId, safeAddress, transactionType, ...params }) { const defaultParams = { safe: `sep:${safeAddress}`, - timezone_offset: '7200000', + timezone: 'Europe/Berlin', trusted: 'false', } diff --git a/apps/web/cypress/support/utils/wallet.js b/apps/web/cypress/support/utils/wallet.js new file mode 100644 index 000000000..9a75493d3 --- /dev/null +++ b/apps/web/cypress/support/utils/wallet.js @@ -0,0 +1,73 @@ +import * as main from '../../e2e/pages/main.page' + +const onboardv2 = 'onboard-v2' +const pkInput = '[data-testid="private-key-input"]' +const pkConnectBtn = '[data-testid="pk-connect-btn"]' +const connectWalletBtn = '[data-testid="connect-wallet-btn"]' + +const privateKeyStr = 'Private key' + +export function connectSigner(signer) { + const actions = { + privateKey: () => { + cy.wait(2000) + cy.get('body').then(($body) => { + if ($body.find(onboardv2).length > 0) { + cy.get(onboardv2) + .shadow() + .find('button') + .contains(privateKeyStr) + .click() + .then(() => handlePkConnect()) + } + }) + }, + retry: () => { + cy.wait(1000).then(enterPrivateKey) + }, + } + + function handlePkConnect() { + cy.get('body').then(($body) => { + if ($body.find(pkInput).length > 0) { + cy.get(pkInput) + .find('input') + .then(($input) => { + $input.val(signer) + cy.wrap($input).trigger('input').trigger('change') + }) + + cy.get(pkConnectBtn).click() + } + }) + } + + function enterPrivateKey() { + cy.wait(3000) + return cy.get('body').then(($body) => { + if ($body.find(pkInput).length > 0) { + cy.get(pkInput) + .find('input') + .then(($input) => { + $input.val(signer) + cy.wrap($input).trigger('input').trigger('change') + }) + + cy.get(pkConnectBtn).click() + } else if ($body.find(connectWalletBtn).length > 0) { + cy.get(connectWalletBtn) + .eq(0) + .should('be.enabled') + .click() + .then(() => { + const actionKey = $body.find(onboardv2).length > 0 ? 'privateKey' : 'retry' + actions[actionKey]() + }) + } + }) + } + + enterPrivateKey().then(() => { + main.closeOutreachPopup() + }) +} diff --git a/apps/web/docs/code-style.md b/apps/web/docs/code-style.md new file mode 100644 index 000000000..82429ac9f --- /dev/null +++ b/apps/web/docs/code-style.md @@ -0,0 +1,39 @@ +# 💝 Code Style Guidelines + +## Principles + +- Rely on automation/IDE +- Strive for pragmatism +- Don’t add bells and whistles (newlines, spaces for “beauty”, ordering imports etc.) +- Avoid unnecessary stylistic changes + - They increase the chance of git conflicts (esp. in imports) + - They make it harder to review the PR + - Ultimately, a waste of time + +## Functional code style + +- Write small functions that do one thing with no side-effects +- Compose small functions to do more things +- Same with components: don’t write giant components, write small composable components +- Prefer `map`/`filter` over `reduce`/`forEach` +- Watch out when using destructive methods like `pop` or `sort` (yes, `sort` is destructive!) +- Avoid initializing things on the module level, prefer to export an init function instead + +## Reactive programming + +- Keep in mind the React component life-cycle, avoid excessive re-renders +- Glue regular JS functions and events with React using hooks +- Write small `useEffect` hooks that do just one thing and have only necessary dependencies + +## Variable/function naming + +Infamously, the hardest problem in computer science. + +- Components are classes, so their names should be in PascalCase +- Config-like constants should be in UPPER_CASE, e.g. `INFURA_URL` +- Regular `const` variables should be in camelCase +- Avoid prepositions in variable names: + - ~`restoreFromLocalStorage`~ 🙅 + - `restoreStoredValue` 👍 +- Try to name boolean vars with `is`, e.g. `isLoading` vs `loading` +- If something needs to be exported just for unit tests, export it with a `_` prefix, e.g. `_getOnboardConfig` diff --git a/apps/web/docs/environments.md b/apps/web/docs/environments.md new file mode 100644 index 000000000..176c6bb9b --- /dev/null +++ b/apps/web/docs/environments.md @@ -0,0 +1,34 @@ +# Environments + +We have several environments where the app can be deployed: + +| Env | URL | Purpose | How it's deployed | Backend env | +| ---------- | ----------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------- | ---------------------------- | +| local | http://localhost:3000/app | local development | `yarn start` | staging | +| PRs | `https://--walletweb.review.5afe.dev/` | peer review & feature QA | for all PRs on push | staging | +| dev | https://safe-wallet-web.dev.5afe.dev/ | preview of all WIP features | on push to the `dev` branch | staging | +| staging | https://safe-wallet-web.staging.5afe.dev/ | preview of features before a release | on push to `main` | **production** (for testing) | +| production | https://app.safe.global/ | live app | deployed by DevOps (see the [Release Procedure](release-procedure.md)) | **production** | + +## Lifecycle of a feature + +After a feature enters the development cycle (i.e. is in a sprint), it goes through the following steps: + +### Development & QA + +1. Developer starts working on the feature +2. Developer creates a Pull Request and assigns a reviewer +3. Reviewer leaves feedback until the PR is approved +4. QA engineer starts testing the branch on a deployed site (each PR has one, see the table above) +5. Once QA gives a green light, the branch is merged to the `dev` branch + +### Release + +1. All merged branches sit on `dev`, which is occasionally reviewed on the [dev site](https://safe-wallet-web.dev.5afe.dev/). +2. In case some regression is noticed, it's fixed on dev. +3. Once a sufficient amount of features are ready for a release (at least once in a sprint), a release branch is made (normally from the HEAD of `dev`) and a PR to `main` is created. +4. QA does regression testing on the release branch. The backend APIs are pointing to production on this branch so that all chains can be tested. +5. Once QA passes, the branch is merged to `main` and is automatically deployed to the [staging site](https://safe-wallet-web.staging.5afe.dev/). +6. It sits on staging for a short while where QA and the release manager briefly do a final check before going live. +7. DevOps are requested to deploy the code from `main` to the production env. +8. Once it's done, brief sanity checks are done on the [production site](https://app.safe.global/). diff --git a/apps/web/docs/release-procedure.md b/apps/web/docs/release-procedure.md new file mode 100644 index 000000000..1842269ac --- /dev/null +++ b/apps/web/docs/release-procedure.md @@ -0,0 +1,73 @@ +# Releasing to production + +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: + +``` + –> dev -> release -> main +``` + +We prepare at least one release every sprint. Sprints are two weeks long. + +### Preparing a release branch + +- Create a code-freeze branch named `release` + - If it's a regular release, this branch is typically based off of `dev` + - For hot fixes, it would be `main` + cherry-picked commits +- Bump the version in the `package.json` as a separate commit with the commit message equal to the exact version +- Create a PR with the list of changes + + > 💡 To generate a quick changelog: + > + > ```bash + > 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` + +### QA + +- The QA team do regression testing on this branch +- If issues are found, bugfixes are merged into this branch +- Once the QA is done, proceed to the next step + +### Releasing to production + +Wait for all the checks on GitHub to pass. + +- Switch to the main branch and make sure it's up to date: + +``` +git checkout main +git fetch --all +git reset --hard origin/main +``` + +- Pull from the release branch: + +``` +git pull origin release +``` + +- Push: + +``` +git push +``` + +A deployment workflow will kick in and do the following things: + +- Deploy the code to staging +- 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 +- 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-terms.md b/apps/web/docs/update-terms.md new file mode 100644 index 000000000..0e7d95832 --- /dev/null +++ b/apps/web/docs/update-terms.md @@ -0,0 +1,28 @@ +# How to update Terms & Conditions + +To update the terms and conditions, follow these steps: + +1. Export the terms and conditions from Google Docs as a Markdown file. +2. Replace the content of the src/markdown/terms/terms.md file with the exported content. +3. If significant changes were made, update the version and last updated date in `version.ts` in the same folder. + +That’s it! + +The updated terms and conditions will be displayed in the app with the correct version number and date. A popup banner +will automatically appear for users who haven’t accepted the new terms. + +## How does this work? + +We rely on the version number from `version.ts`. When the Redux store is rehydrated, we check the version stored in +the store against the version in the frontmatter. If they differ, we reset the accepted terms, forcing the user to +accept the new version. + +The Markdown file is automatically converted to HTML and displayed in the app. Note that because the Markdown was +generated +from Google Docs, we require the remark-heading-id plugin. Additionally, since Google Docs uses {# ...} syntax, it will +fail in an MDX file. + +For Cypress, we follow a similar process. We read the version from the frontmatter and pass it as an environment +variable. + +For Jest tests, we mock the file and read the version from the mock. diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 000000000..8e65ad8ad --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,77 @@ +import unusedImports from 'eslint-plugin-unused-imports' +import typescriptEslint from '@typescript-eslint/eslint-plugin' +import noOnlyTests from 'eslint-plugin-no-only-tests' +import tsParser from '@typescript-eslint/parser' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import js from '@eslint/js' +import { FlatCompat } from '@eslint/eslintrc' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}) + +export default [ + { + ignores: ['**/node_modules/', '**/.next/', '**/.github/', '**/cypress/', '**/src/types/contracts/'], + }, + ...compat.extends('next', 'prettier', 'plugin:prettier/recommended', 'plugin:storybook/recommended'), + { + plugins: { + 'unused-imports': unusedImports, + '@typescript-eslint': typescriptEslint, + 'no-only-tests': noOnlyTests, + }, + + languageOptions: { + parser: tsParser, + ecmaVersion: 5, + sourceType: 'script', + + parserOptions: { + project: ['./tsconfig.json'], + }, + }, + + rules: { + '@next/next/no-img-element': 'off', + '@next/next/google-font-display': 'off', + '@next/next/google-font-preconnect': 'off', + '@next/next/no-page-custom-font': 'off', + 'unused-imports/no-unused-imports': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/await-thenable': 'error', + 'no-constant-condition': 'warn', + + 'unused-imports/no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_', + }, + ], + + 'react-hooks/exhaustive-deps': [ + 'warn', + { + additionalHooks: 'useAsync', + }, + ], + + 'no-only-tests/no-only-tests': 'error', + 'object-shorthand': ['error', 'properties'], + 'jsx-quotes': ['error', 'prefer-double'], + + 'react/jsx-curly-brace-presence': [ + 'error', + { + props: 'never', + children: 'never', + }, + ], + }, + }, +] diff --git a/apps/web/jest.config.cjs b/apps/web/jest.config.cjs new file mode 100644 index 000000000..36ee0b724 --- /dev/null +++ b/apps/web/jest.config.cjs @@ -0,0 +1,39 @@ +const nextJest = require('next/jest') +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: './', +}) + +// Add any custom config to be passed to Jest +const customJestConfig = { + setupFilesAfterEnv: ['/jest.setup.js'], + + moduleNameMapper: { + // Handle module aliases (this will be automatically configured for you soon) + '^@/(.*)$': '/src/$1', + '^react$': '/node_modules/react', + '^react-dom$': '/node_modules/react-dom', + '^.+\\.(svg)$': '/mocks/svg.js', + '^.+/markdown/terms/terms\\.md$': '/mocks/terms.md.js', + isows: '/node_modules/isows/_cjs/index.js', + }, + // https://github.com/mswjs/jest-fixed-jsdom + // without this environment it is basically impossible to run tests with msw + testEnvironment: 'jest-fixed-jsdom', + + testEnvironmentOptions: { + url: 'http://localhost/balances?safe=rin:0xb3b83bf204C458B461de9B0CD2739DB152b4fa5A', + // https://github.com/mswjs/msw/issues/1786#issuecomment-2426900455 + // without this line 4 tests related to firefox fail + customExportConditions: ['node'], + }, + coveragePathIgnorePatterns: ['/node_modules/', '/src/tests/', '/src/types/contracts/'], +} + +// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async +module.exports = async () => ({ + ...(await createJestConfig(customJestConfig)()), + transformIgnorePatterns: [ + 'node_modules/(?!(uint8arrays|multiformats|@web3-onboard/common|@walletconnect/(.*)/uint8arrays)/)', + ], +}) diff --git a/apps/web/jest.setup.js b/apps/web/jest.setup.js new file mode 100644 index 000000000..9877ee23e --- /dev/null +++ b/apps/web/jest.setup.js @@ -0,0 +1,52 @@ +// Used for __tests__/testing-library.js +// Learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom' +import { server } from '@/tests/server' + +jest.mock('@web3-onboard/coinbase', () => jest.fn()) +jest.mock('@web3-onboard/injected-wallets', () => ({ ProviderLabel: { MetaMask: 'MetaMask' } })) +jest.mock('@web3-onboard/walletconnect', () => jest.fn()) +jest.mock('@safe-global/safe-client-gateway-sdk') + +const mockOnboardState = { + chains: [], + walletModules: [], + wallets: [], + accountCenter: {}, +} + +jest.mock('@web3-onboard/core', () => () => ({ + connectWallet: jest.fn(), + disconnectWallet: jest.fn(), + setChain: jest.fn(), + state: { + select: (key) => ({ + subscribe: (next) => { + next(mockOnboardState[key]) + + return { + unsubscribe: jest.fn(), + } + }, + }), + get: () => mockOnboardState, + }, +})) + +// This is required for jest.spyOn to work with imported modules. +// After Next 13, imported modules have `configurable: false` for named exports, +// which means that `jest.spyOn` cannot modify the exported function. +const defineProperty = Object.defineProperty +Object.defineProperty = (obj, prop, desc) => { + if (prop !== 'prototype') { + desc.configurable = true + } + return defineProperty(obj, prop, desc) +} + +beforeAll(() => { + server.listen() +}) + +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) diff --git a/mocks/svg.js b/apps/web/mocks/svg.js similarity index 100% rename from mocks/svg.js rename to apps/web/mocks/svg.js diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 000000000..52e831b43 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 000000000..4ede22b48 --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,115 @@ +import path from 'path' +import withBundleAnalyzer from '@next/bundle-analyzer' +import withPWAInit from '@ducanh2912/next-pwa' +import remarkGfm from 'remark-gfm' +import remarkHeadingId from 'remark-heading-id' +import createMDX from '@next/mdx' +import remarkFrontmatter from 'remark-frontmatter' +import remarkMdxFrontmatter from 'remark-mdx-frontmatter' + +const SERVICE_WORKERS_PATH = './src/service-workers' + +const withPWA = withPWAInit({ + dest: 'public', + workboxOptions: { + mode: 'production', + }, + reloadOnOnline: false, + /* Do not precache anything */ + publicExcludes: ['**/*'], + buildExcludes: [/./], + customWorkerSrc: SERVICE_WORKERS_PATH, + // Prefer InjectManifest for Web Push + swSrc: `${SERVICE_WORKERS_PATH}/index.ts`, +}) + +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'export', // static site export + + transpilePackages: ['@safe-global/store'], + images: { + unoptimized: true, + }, + + pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], + reactStrictMode: false, + productionBrowserSourceMaps: true, + eslint: { + dirs: ['src', 'cypress'], + }, + experimental: { + optimizePackageImports: [ + '@mui/material', + '@mui/icons-material', + 'lodash', + 'date-fns', + '@sentry/react', + '@gnosis.pm/zodiac', + ], + }, + webpack(config, { dev }) { + config.module.rules.push({ + test: /\.svg$/i, + issuer: { and: [/\.(js|ts|md)x?$/] }, + use: [ + { + loader: '@svgr/webpack', + options: { + prettier: false, + svgo: false, + svgoConfig: { + plugins: [ + { + name: 'preset-default', + params: { + overrides: { removeViewBox: false }, + }, + }, + ], + }, + titleProp: true, + }, + }, + ], + }) + + config.resolve.alias = { + ...config.resolve.alias, + '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'), + } + + if (dev) { + config.optimization.splitChunks = { + ...config.optimization.splitChunks, + cacheGroups: { + ...config.optimization.splitChunks.cacheGroups, + customModule: { + test: /[\\/]..[\\/]..[\\/]node_modules[\\/](@safe-global|ethers)[\\/]/, + name: 'protocol-kit-ethers', + chunks: 'all', + }, + }, + } + config.optimization.minimize = false + } + + return config + }, +} +const withMDX = createMDX({ + extension: /\.(md|mdx)?$/, + jsx: true, + options: { + remarkPlugins: [remarkFrontmatter, [remarkMdxFrontmatter, { name: 'metadata' }], remarkHeadingId, remarkGfm], + rehypePlugins: [], + }, +}) + +export default withBundleAnalyzer({ + enabled: process.env.ANALYZE === 'true', +})(withPWA(withMDX(nextConfig))) diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..6f029f185 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,175 @@ +{ + "name": "@safe-global/web", + "homepage": "https://github.com/safe-global/safe-wallet-web", + "license": "GPL-3.0", + "version": "1.51.3", + "type": "module", + "scripts": { + "dev": "next dev", + "start": "next dev", + "build": "next build", + "lint": "tsc && next lint", + "lint:fix": "next lint --fix", + "prettier": "prettier -w \"{src,cypress,mocks,scripts}/**/*.{ts,tsx,css,js}\"", + "fix": "yarn lint:fix && ts-prune && yarn prettier", + "test": "cross-env TZ=CET DEBUG_PRINT_LIMIT=30000 jest", + "test:ci": "yarn test --ci --silent --coverage --json --watchAll=false --testLocationInResults --outputFile=report.json", + "test:coverage": "yarn test --coverage --watchAll=false", + "cmp": "./scripts/cmp.sh", + "routes": "node scripts/generate-routes.js > src/config/routes.ts && prettier -w src/config/routes.ts && cat src/config/routes.ts", + "css-vars": "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", + "after-install": "yarn generate-types", + "postinstall": "yarn after-install", + "analyze": "cross-env ANALYZE=true yarn build", + "cypress:open": "cross-env TZ=UTC cypress open --e2e", + "cypress:canary": "cross-env TZ=UTC cypress open --e2e -b chrome:canary", + "cypress:run": "cypress run", + "cypress:ci": "yarn cypress:run --config baseUrl=http://localhost:8080 --spec cypress/e2e/smoke/*.cy.js", + "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" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@cowprotocol/widget-react": "^0.13.0", + "@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.1.0", + "@ledgerhq/device-management-kit": "^0.5.1", + "@ledgerhq/device-signer-kit-ethereum": "^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", + "@safe-global/api-kit": "^2.4.6", + "@safe-global/protocol-kit": "^4.1.5", + "@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.36#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.36-7beb5df7ca.patch", + "@safe-global/safe-gateway-typescript-sdk": "3.22.7", + "@safe-global/safe-modules-deployments": "patch:@safe-global/safe-modules-deployments@npm%3A2.2.6#~/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.6-78a093615c.patch", + "@safe-global/store": "workspace:^", + "@sentry/react": "^7.91.0", + "@spindl-xyz/attribution-lite": "^1.4.0", + "@walletconnect/core": "^2.17.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/ledger": "patch:@web3-onboard/ledger@npm%3A2.3.2#~/.yarn/patches/@web3-onboard-ledger-npm-2.3.2-d2260df52b.patch", + "@web3-onboard/trezor": "2.4.3", + "@web3-onboard/walletconnect": "^2.6.1", + "blo": "^1.1.1", + "classnames": "^2.5.1", + "date-fns": "^2.30.0", + "ethers": "^6.13.4", + "exponential-backoff": "^3.1.0", + "firebase": "^11.1.0", + "fuse.js": "^7.0.0", + "idb-keyval": "^6.2.1", + "js-cookie": "^3.0.1", + "lodash": "^4.17.21", + "next": "^15.1.2", + "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", + "zodiac-roles-deployments": "^2.3.4" + }, + "devDependencies": { + "@chromatic-com/storybook": "^1.3.1", + "@cowprotocol/app-data": "^2.4.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.18.0", + "@faker-js/faker": "^9.0.3", + "@mdx-js/loader": "^3.0.1", + "@mdx-js/react": "^3.0.1", + "@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:^", + "@sentry/types": "^7.74.0", + "@storybook/addon-designs": "^8.0.3", + "@storybook/addon-essentials": "^8.0.6", + "@storybook/addon-interactions": "^8.0.6", + "@storybook/addon-links": "^8.3.4", + "@storybook/addon-onboarding": "^8.0.6", + "@storybook/addon-themes": "^8.0.6", + "@storybook/blocks": "^8.0.6", + "@storybook/nextjs": "^8.0.6", + "@storybook/react": "^8.0.6", + "@storybook/test": "^8.0.6", + "@svgr/webpack": "^8.1.0", + "@testing-library/cypress": "^10.0.2", + "@testing-library/jest-dom": "^6.6.3", + "@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/js-cookie": "^3.0.6", + "@types/lodash": "^4.14.182", + "@types/mdx": "^2.0.13", + "@types/node": "18.11.18", + "@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", + "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-config-next": "^15.0.4", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-no-only-tests": "^3.3.0", + "eslint-plugin-prettier": "^5.2.1", + "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", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-heading-id": "^1.0.1", + "remark-mdx-frontmatter": "^5.0.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" + }, + "nextBundleAnalysis": { + "budget": null, + "budgetPercentIncreaseRed": 20, + "minimumChangeThreshold": 0, + "showDetails": true + }, + "packageManager": "yarn@4.5.3" +} diff --git a/public/.well-known/apple-app-site-association b/apps/web/public/.well-known/apple-app-site-association similarity index 100% rename from public/.well-known/apple-app-site-association rename to apps/web/public/.well-known/apple-app-site-association diff --git a/public/favicon.ico b/apps/web/public/favicon.ico similarity index 100% rename from public/favicon.ico rename to apps/web/public/favicon.ico diff --git a/public/favicons/android-chrome-192x192.png b/apps/web/public/favicons/android-chrome-192x192.png similarity index 100% rename from public/favicons/android-chrome-192x192.png rename to apps/web/public/favicons/android-chrome-192x192.png diff --git a/public/favicons/android-chrome-512x512.png b/apps/web/public/favicons/android-chrome-512x512.png similarity index 100% rename from public/favicons/android-chrome-512x512.png rename to apps/web/public/favicons/android-chrome-512x512.png diff --git a/public/favicons/apple-touch-icon.png b/apps/web/public/favicons/apple-touch-icon.png similarity index 100% rename from public/favicons/apple-touch-icon.png rename to apps/web/public/favicons/apple-touch-icon.png diff --git a/public/favicons/favicon-16x16.png b/apps/web/public/favicons/favicon-16x16.png similarity index 100% rename from public/favicons/favicon-16x16.png rename to apps/web/public/favicons/favicon-16x16.png diff --git a/public/favicons/favicon-32x32.png b/apps/web/public/favicons/favicon-32x32.png similarity index 100% rename from public/favicons/favicon-32x32.png rename to apps/web/public/favicons/favicon-32x32.png diff --git a/public/favicons/favicon-dot.ico b/apps/web/public/favicons/favicon-dot.ico similarity index 100% rename from public/favicons/favicon-dot.ico rename to apps/web/public/favicons/favicon-dot.ico diff --git a/public/favicons/favicon.ico b/apps/web/public/favicons/favicon.ico similarity index 100% rename from public/favicons/favicon.ico rename to apps/web/public/favicons/favicon.ico diff --git a/public/favicons/logo_120x120.png b/apps/web/public/favicons/logo_120x120.png similarity index 100% rename from public/favicons/logo_120x120.png rename to apps/web/public/favicons/logo_120x120.png diff --git a/public/favicons/mstile-144x144.png b/apps/web/public/favicons/mstile-144x144.png similarity index 100% rename from public/favicons/mstile-144x144.png rename to apps/web/public/favicons/mstile-144x144.png diff --git a/public/favicons/mstile-150x150.png b/apps/web/public/favicons/mstile-150x150.png similarity index 100% rename from public/favicons/mstile-150x150.png rename to apps/web/public/favicons/mstile-150x150.png diff --git a/public/favicons/mstile-310x150.png b/apps/web/public/favicons/mstile-310x150.png similarity index 100% rename from public/favicons/mstile-310x150.png rename to apps/web/public/favicons/mstile-310x150.png diff --git a/public/favicons/mstile-310x310.png b/apps/web/public/favicons/mstile-310x310.png similarity index 100% rename from public/favicons/mstile-310x310.png rename to apps/web/public/favicons/mstile-310x310.png diff --git a/public/favicons/mstile-70x70.png b/apps/web/public/favicons/mstile-70x70.png similarity index 100% rename from public/favicons/mstile-70x70.png rename to apps/web/public/favicons/mstile-70x70.png diff --git a/public/favicons/safari-pinned-tab.svg b/apps/web/public/favicons/safari-pinned-tab.svg similarity index 100% rename from public/favicons/safari-pinned-tab.svg rename to apps/web/public/favicons/safari-pinned-tab.svg diff --git a/public/fonts/DMSans700.woff2 b/apps/web/public/fonts/DMSans700.woff2 similarity index 100% rename from public/fonts/DMSans700.woff2 rename to apps/web/public/fonts/DMSans700.woff2 diff --git a/public/fonts/DMSansRegular.woff2 b/apps/web/public/fonts/DMSansRegular.woff2 similarity index 100% rename from public/fonts/DMSansRegular.woff2 rename to apps/web/public/fonts/DMSansRegular.woff2 diff --git a/apps/web/public/fonts/fonts.css b/apps/web/public/fonts/fonts.css new file mode 100644 index 000000000..4c1a1dc16 --- /dev/null +++ b/apps/web/public/fonts/fonts.css @@ -0,0 +1,15 @@ +@font-face { + font-family: 'DM Sans'; + font-display: swap; + font-weight: 400; + /** check that the font is loaded on the website. IDEs fail to find the file */ + src: url('/fonts/DMSansRegular.woff2') format('woff2'); +} + +@font-face { + font-family: 'DM Sans'; + font-display: swap; + font-weight: bold; + /** check that the font is loaded on the website. IDEs fail to find the file */ + src: url('/fonts/DMSans700.woff2') format('woff2'); +} diff --git a/public/images/address-book/address-book.svg b/apps/web/public/images/address-book/address-book.svg similarity index 100% rename from public/images/address-book/address-book.svg rename to apps/web/public/images/address-book/address-book.svg diff --git a/public/images/address-book/no-entries.svg b/apps/web/public/images/address-book/no-entries.svg similarity index 100% rename from public/images/address-book/no-entries.svg rename to apps/web/public/images/address-book/no-entries.svg diff --git a/public/images/apps/add-custom-app.svg b/apps/web/public/images/apps/add-custom-app.svg similarity index 100% rename from public/images/apps/add-custom-app.svg rename to apps/web/public/images/apps/add-custom-app.svg diff --git a/public/images/apps/app-placeholder.svg b/apps/web/public/images/apps/app-placeholder.svg similarity index 100% rename from public/images/apps/app-placeholder.svg rename to apps/web/public/images/apps/app-placeholder.svg diff --git a/public/images/apps/apps-demo.svg b/apps/web/public/images/apps/apps-demo.svg similarity index 100% rename from public/images/apps/apps-demo.svg rename to apps/web/public/images/apps/apps-demo.svg diff --git a/public/images/apps/apps-icon.svg b/apps/web/public/images/apps/apps-icon.svg similarity index 100% rename from public/images/apps/apps-icon.svg rename to apps/web/public/images/apps/apps-icon.svg diff --git a/public/images/apps/batch-icon.svg b/apps/web/public/images/apps/batch-icon.svg similarity index 100% rename from public/images/apps/batch-icon.svg rename to apps/web/public/images/apps/batch-icon.svg diff --git a/public/images/apps/bookmark.svg b/apps/web/public/images/apps/bookmark.svg similarity index 100% rename from public/images/apps/bookmark.svg rename to apps/web/public/images/apps/bookmark.svg diff --git a/public/images/apps/bookmarked.svg b/apps/web/public/images/apps/bookmarked.svg similarity index 100% rename from public/images/apps/bookmarked.svg rename to apps/web/public/images/apps/bookmarked.svg diff --git a/public/images/apps/code-icon.svg b/apps/web/public/images/apps/code-icon.svg similarity index 100% rename from public/images/apps/code-icon.svg rename to apps/web/public/images/apps/code-icon.svg diff --git a/public/images/apps/explore.svg b/apps/web/public/images/apps/explore.svg similarity index 100% rename from public/images/apps/explore.svg rename to apps/web/public/images/apps/explore.svg diff --git a/public/images/apps/grid-view-icon.svg b/apps/web/public/images/apps/grid-view-icon.svg similarity index 100% rename from public/images/apps/grid-view-icon.svg rename to apps/web/public/images/apps/grid-view-icon.svg diff --git a/public/images/apps/list-view-icon.svg b/apps/web/public/images/apps/list-view-icon.svg similarity index 100% rename from public/images/apps/list-view-icon.svg rename to apps/web/public/images/apps/list-view-icon.svg diff --git a/public/images/apps/network-error.svg b/apps/web/public/images/apps/network-error.svg similarity index 100% rename from public/images/apps/network-error.svg rename to apps/web/public/images/apps/network-error.svg diff --git a/public/images/balances/no-assets.svg b/apps/web/public/images/balances/no-assets.svg similarity index 100% rename from public/images/balances/no-assets.svg rename to apps/web/public/images/balances/no-assets.svg diff --git a/public/images/common/add-outlined.svg b/apps/web/public/images/common/add-outlined.svg similarity index 100% rename from public/images/common/add-outlined.svg rename to apps/web/public/images/common/add-outlined.svg diff --git a/public/images/common/add.svg b/apps/web/public/images/common/add.svg similarity index 100% rename from public/images/common/add.svg rename to apps/web/public/images/common/add.svg diff --git a/public/images/common/alert.svg b/apps/web/public/images/common/alert.svg similarity index 100% rename from public/images/common/alert.svg rename to apps/web/public/images/common/alert.svg diff --git a/apps/web/public/images/common/arrow-down.svg b/apps/web/public/images/common/arrow-down.svg new file mode 100644 index 000000000..8e0969f57 --- /dev/null +++ b/apps/web/public/images/common/arrow-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/common/arrow-nw.svg b/apps/web/public/images/common/arrow-nw.svg similarity index 100% rename from public/images/common/arrow-nw.svg rename to apps/web/public/images/common/arrow-nw.svg diff --git a/public/images/common/arrow-se.svg b/apps/web/public/images/common/arrow-se.svg similarity index 100% rename from public/images/common/arrow-se.svg rename to apps/web/public/images/common/arrow-se.svg diff --git a/public/images/common/arrow-top-right.svg b/apps/web/public/images/common/arrow-top-right.svg similarity index 100% rename from public/images/common/arrow-top-right.svg rename to apps/web/public/images/common/arrow-top-right.svg diff --git a/public/images/common/asterix.svg b/apps/web/public/images/common/asterix.svg similarity index 100% rename from public/images/common/asterix.svg rename to apps/web/public/images/common/asterix.svg diff --git a/public/images/common/bar-chart.svg b/apps/web/public/images/common/bar-chart.svg similarity index 100% rename from public/images/common/bar-chart.svg rename to apps/web/public/images/common/bar-chart.svg diff --git a/public/images/common/batch.svg b/apps/web/public/images/common/batch.svg similarity index 100% rename from public/images/common/batch.svg rename to apps/web/public/images/common/batch.svg diff --git a/public/images/common/block.svg b/apps/web/public/images/common/block.svg similarity index 100% rename from public/images/common/block.svg rename to apps/web/public/images/common/block.svg diff --git a/apps/web/public/images/common/bridge.svg b/apps/web/public/images/common/bridge.svg new file mode 100644 index 000000000..db47ec7f6 --- /dev/null +++ b/apps/web/public/images/common/bridge.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/images/common/cancel.svg b/apps/web/public/images/common/cancel.svg similarity index 100% rename from public/images/common/cancel.svg rename to apps/web/public/images/common/cancel.svg diff --git a/public/images/common/caret-down.svg b/apps/web/public/images/common/caret-down.svg similarity index 100% rename from public/images/common/caret-down.svg rename to apps/web/public/images/common/caret-down.svg diff --git a/public/images/common/check-filled.svg b/apps/web/public/images/common/check-filled.svg similarity index 100% rename from public/images/common/check-filled.svg rename to apps/web/public/images/common/check-filled.svg diff --git a/public/images/common/check.svg b/apps/web/public/images/common/check.svg similarity index 100% rename from public/images/common/check.svg rename to apps/web/public/images/common/check.svg diff --git a/public/images/common/circle-check.svg b/apps/web/public/images/common/circle-check.svg similarity index 100% rename from public/images/common/circle-check.svg rename to apps/web/public/images/common/circle-check.svg diff --git a/public/images/common/circle-partial-fill.svg b/apps/web/public/images/common/circle-partial-fill.svg similarity index 100% rename from public/images/common/circle-partial-fill.svg rename to apps/web/public/images/common/circle-partial-fill.svg diff --git a/public/images/common/circle.svg b/apps/web/public/images/common/circle.svg similarity index 100% rename from public/images/common/circle.svg rename to apps/web/public/images/common/circle.svg diff --git a/public/images/common/clock.svg b/apps/web/public/images/common/clock.svg similarity index 100% rename from public/images/common/clock.svg rename to apps/web/public/images/common/clock.svg diff --git a/public/images/common/close.svg b/apps/web/public/images/common/close.svg similarity index 100% rename from public/images/common/close.svg rename to apps/web/public/images/common/close.svg diff --git a/public/images/common/connection-dots.svg b/apps/web/public/images/common/connection-dots.svg similarity index 100% rename from public/images/common/connection-dots.svg rename to apps/web/public/images/common/connection-dots.svg diff --git a/public/images/common/copy.svg b/apps/web/public/images/common/copy.svg similarity index 100% rename from public/images/common/copy.svg rename to apps/web/public/images/common/copy.svg diff --git a/public/images/common/created.svg b/apps/web/public/images/common/created.svg similarity index 100% rename from public/images/common/created.svg rename to apps/web/public/images/common/created.svg diff --git a/public/images/common/delete.svg b/apps/web/public/images/common/delete.svg similarity index 100% rename from public/images/common/delete.svg rename to apps/web/public/images/common/delete.svg diff --git a/public/images/common/discord-icon.svg b/apps/web/public/images/common/discord-icon.svg similarity index 100% rename from public/images/common/discord-icon.svg rename to apps/web/public/images/common/discord-icon.svg diff --git a/public/images/common/document_signature.svg b/apps/web/public/images/common/document_signature.svg similarity index 100% rename from public/images/common/document_signature.svg rename to apps/web/public/images/common/document_signature.svg diff --git a/public/images/common/dot.svg b/apps/web/public/images/common/dot.svg similarity index 100% rename from public/images/common/dot.svg rename to apps/web/public/images/common/dot.svg diff --git a/apps/web/public/images/common/download-cloud.svg b/apps/web/public/images/common/download-cloud.svg new file mode 100644 index 000000000..b6dd6403c --- /dev/null +++ b/apps/web/public/images/common/download-cloud.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/common/drag.svg b/apps/web/public/images/common/drag.svg similarity index 100% rename from public/images/common/drag.svg rename to apps/web/public/images/common/drag.svg diff --git a/public/images/common/edit.svg b/apps/web/public/images/common/edit.svg similarity index 100% rename from public/images/common/edit.svg rename to apps/web/public/images/common/edit.svg diff --git a/public/images/common/empty-batch.svg b/apps/web/public/images/common/empty-batch.svg similarity index 100% rename from public/images/common/empty-batch.svg rename to apps/web/public/images/common/empty-batch.svg diff --git a/public/images/common/error.png b/apps/web/public/images/common/error.png similarity index 100% rename from public/images/common/error.png rename to apps/web/public/images/common/error.png diff --git a/public/images/common/export.svg b/apps/web/public/images/common/export.svg similarity index 100% rename from public/images/common/export.svg rename to apps/web/public/images/common/export.svg diff --git a/public/images/common/gas-station.svg b/apps/web/public/images/common/gas-station.svg similarity index 100% rename from public/images/common/gas-station.svg rename to apps/web/public/images/common/gas-station.svg diff --git a/public/images/common/gnosis-chain-logo.png b/apps/web/public/images/common/gnosis-chain-logo.png similarity index 100% rename from public/images/common/gnosis-chain-logo.png rename to apps/web/public/images/common/gnosis-chain-logo.png diff --git a/public/images/common/ic-rocket-speedup.svg b/apps/web/public/images/common/ic-rocket-speedup.svg similarity index 100% rename from public/images/common/ic-rocket-speedup.svg rename to apps/web/public/images/common/ic-rocket-speedup.svg diff --git a/public/images/common/ic-swaps.svg b/apps/web/public/images/common/ic-swaps.svg similarity index 100% rename from public/images/common/ic-swaps.svg rename to apps/web/public/images/common/ic-swaps.svg diff --git a/public/images/common/import.svg b/apps/web/public/images/common/import.svg similarity index 100% rename from public/images/common/import.svg rename to apps/web/public/images/common/import.svg diff --git a/apps/web/public/images/common/kiln.svg b/apps/web/public/images/common/kiln.svg new file mode 100644 index 000000000..865f20655 --- /dev/null +++ b/apps/web/public/images/common/kiln.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/common/lightbulb.svg b/apps/web/public/images/common/lightbulb.svg similarity index 100% rename from public/images/common/lightbulb.svg rename to apps/web/public/images/common/lightbulb.svg diff --git a/public/images/common/link.svg b/apps/web/public/images/common/link.svg similarity index 100% rename from public/images/common/link.svg rename to apps/web/public/images/common/link.svg diff --git a/public/images/common/loading.svg b/apps/web/public/images/common/loading.svg similarity index 100% rename from public/images/common/loading.svg rename to apps/web/public/images/common/loading.svg diff --git a/public/images/common/lock-small.svg b/apps/web/public/images/common/lock-small.svg similarity index 100% rename from public/images/common/lock-small.svg rename to apps/web/public/images/common/lock-small.svg diff --git a/public/images/common/lock-warning.svg b/apps/web/public/images/common/lock-warning.svg similarity index 100% rename from public/images/common/lock-warning.svg rename to apps/web/public/images/common/lock-warning.svg diff --git a/public/images/common/lock.svg b/apps/web/public/images/common/lock.svg similarity index 100% rename from public/images/common/lock.svg rename to apps/web/public/images/common/lock.svg diff --git a/public/images/common/minus.svg b/apps/web/public/images/common/minus.svg similarity index 100% rename from public/images/common/minus.svg rename to apps/web/public/images/common/minus.svg diff --git a/apps/web/public/images/common/multisend.svg b/apps/web/public/images/common/multisend.svg new file mode 100644 index 000000000..17a4cd2b8 --- /dev/null +++ b/apps/web/public/images/common/multisend.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/common/network-error.svg b/apps/web/public/images/common/network-error.svg similarity index 100% rename from public/images/common/network-error.svg rename to apps/web/public/images/common/network-error.svg diff --git a/public/images/common/nft-atomic0.svg b/apps/web/public/images/common/nft-atomic0.svg similarity index 100% rename from public/images/common/nft-atomic0.svg rename to apps/web/public/images/common/nft-atomic0.svg diff --git a/public/images/common/nft-blur.svg b/apps/web/public/images/common/nft-blur.svg similarity index 100% rename from public/images/common/nft-blur.svg rename to apps/web/public/images/common/nft-blur.svg diff --git a/public/images/common/nft-etherscan.svg b/apps/web/public/images/common/nft-etherscan.svg similarity index 100% rename from public/images/common/nft-etherscan.svg rename to apps/web/public/images/common/nft-etherscan.svg diff --git a/public/images/common/nft-gnosisscan.svg b/apps/web/public/images/common/nft-gnosisscan.svg similarity index 100% rename from public/images/common/nft-gnosisscan.svg rename to apps/web/public/images/common/nft-gnosisscan.svg diff --git a/public/images/common/nft-opensea.svg b/apps/web/public/images/common/nft-opensea.svg similarity index 100% rename from public/images/common/nft-opensea.svg rename to apps/web/public/images/common/nft-opensea.svg diff --git a/public/images/common/nft-placeholder.png b/apps/web/public/images/common/nft-placeholder.png similarity index 100% rename from public/images/common/nft-placeholder.png rename to apps/web/public/images/common/nft-placeholder.png diff --git a/public/images/common/nft-polygonscan.svg b/apps/web/public/images/common/nft-polygonscan.svg similarity index 100% rename from public/images/common/nft-polygonscan.svg rename to apps/web/public/images/common/nft-polygonscan.svg diff --git a/public/images/common/nft-zapper.svg b/apps/web/public/images/common/nft-zapper.svg similarity index 100% rename from public/images/common/nft-zapper.svg rename to apps/web/public/images/common/nft-zapper.svg diff --git a/public/images/common/nft-zerion.svg b/apps/web/public/images/common/nft-zerion.svg similarity index 100% rename from public/images/common/nft-zerion.svg rename to apps/web/public/images/common/nft-zerion.svg diff --git a/public/images/common/nft.svg b/apps/web/public/images/common/nft.svg similarity index 100% rename from public/images/common/nft.svg rename to apps/web/public/images/common/nft.svg diff --git a/public/images/common/notifications.svg b/apps/web/public/images/common/notifications.svg similarity index 100% rename from public/images/common/notifications.svg rename to apps/web/public/images/common/notifications.svg diff --git a/apps/web/public/images/common/outreach-popup-avatar.png b/apps/web/public/images/common/outreach-popup-avatar.png new file mode 100644 index 000000000..100db4a0c Binary files /dev/null and b/apps/web/public/images/common/outreach-popup-avatar.png differ diff --git a/public/images/common/owners.svg b/apps/web/public/images/common/owners.svg similarity index 100% rename from public/images/common/owners.svg rename to apps/web/public/images/common/owners.svg diff --git a/public/images/common/plus.svg b/apps/web/public/images/common/plus.svg similarity index 100% rename from public/images/common/plus.svg rename to apps/web/public/images/common/plus.svg diff --git a/public/images/common/propose-recovery-dark.svg b/apps/web/public/images/common/propose-recovery-dark.svg similarity index 100% rename from public/images/common/propose-recovery-dark.svg rename to apps/web/public/images/common/propose-recovery-dark.svg diff --git a/public/images/common/propose-recovery-light.svg b/apps/web/public/images/common/propose-recovery-light.svg similarity index 100% rename from public/images/common/propose-recovery-light.svg rename to apps/web/public/images/common/propose-recovery-light.svg diff --git a/public/images/common/qr.svg b/apps/web/public/images/common/qr.svg similarity index 100% rename from public/images/common/qr.svg rename to apps/web/public/images/common/qr.svg diff --git a/public/images/common/question.svg b/apps/web/public/images/common/question.svg similarity index 100% rename from public/images/common/question.svg rename to apps/web/public/images/common/question.svg diff --git a/public/images/common/ramp_logo.svg b/apps/web/public/images/common/ramp_logo.svg similarity index 100% rename from public/images/common/ramp_logo.svg rename to apps/web/public/images/common/ramp_logo.svg diff --git a/public/images/common/recovery-pending.svg b/apps/web/public/images/common/recovery-pending.svg similarity index 100% rename from public/images/common/recovery-pending.svg rename to apps/web/public/images/common/recovery-pending.svg diff --git a/public/images/common/recovery-plus.svg b/apps/web/public/images/common/recovery-plus.svg similarity index 100% rename from public/images/common/recovery-plus.svg rename to apps/web/public/images/common/recovery-plus.svg diff --git a/public/images/common/recovery.svg b/apps/web/public/images/common/recovery.svg similarity index 100% rename from public/images/common/recovery.svg rename to apps/web/public/images/common/recovery.svg diff --git a/public/images/common/recovery_custom.svg b/apps/web/public/images/common/recovery_custom.svg similarity index 100% rename from public/images/common/recovery_custom.svg rename to apps/web/public/images/common/recovery_custom.svg diff --git a/apps/web/public/images/common/recovery_sygnum.svg b/apps/web/public/images/common/recovery_sygnum.svg new file mode 100644 index 000000000..2644602e5 --- /dev/null +++ b/apps/web/public/images/common/recovery_sygnum.svg @@ -0,0 +1 @@ + diff --git a/public/images/common/relayer.svg b/apps/web/public/images/common/relayer.svg similarity index 100% rename from public/images/common/relayer.svg rename to apps/web/public/images/common/relayer.svg diff --git a/public/images/common/rocket.svg b/apps/web/public/images/common/rocket.svg similarity index 100% rename from public/images/common/rocket.svg rename to apps/web/public/images/common/rocket.svg diff --git a/public/images/common/safe-pass-logo.svg b/apps/web/public/images/common/safe-pass-logo.svg similarity index 100% rename from public/images/common/safe-pass-logo.svg rename to apps/web/public/images/common/safe-pass-logo.svg diff --git a/apps/web/public/images/common/safe-pass-star.svg b/apps/web/public/images/common/safe-pass-star.svg new file mode 100644 index 000000000..2cff53958 --- /dev/null +++ b/apps/web/public/images/common/safe-pass-star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/common/safe-swap-dark.svg b/apps/web/public/images/common/safe-swap-dark.svg similarity index 100% rename from public/images/common/safe-swap-dark.svg rename to apps/web/public/images/common/safe-swap-dark.svg diff --git a/public/images/common/safe-swap.svg b/apps/web/public/images/common/safe-swap.svg similarity index 100% rename from public/images/common/safe-swap.svg rename to apps/web/public/images/common/safe-swap.svg diff --git a/public/images/common/safe-token.svg b/apps/web/public/images/common/safe-token.svg similarity index 100% rename from public/images/common/safe-token.svg rename to apps/web/public/images/common/safe-token.svg diff --git a/public/images/common/save-address.svg b/apps/web/public/images/common/save-address.svg similarity index 100% rename from public/images/common/save-address.svg rename to apps/web/public/images/common/save-address.svg diff --git a/public/images/common/search.svg b/apps/web/public/images/common/search.svg similarity index 100% rename from public/images/common/search.svg rename to apps/web/public/images/common/search.svg diff --git a/public/images/common/share.svg b/apps/web/public/images/common/share.svg similarity index 100% rename from public/images/common/share.svg rename to apps/web/public/images/common/share.svg diff --git a/public/images/common/shield-off.svg b/apps/web/public/images/common/shield-off.svg similarity index 100% rename from public/images/common/shield-off.svg rename to apps/web/public/images/common/shield-off.svg diff --git a/public/images/common/shield.svg b/apps/web/public/images/common/shield.svg similarity index 100% rename from public/images/common/shield.svg rename to apps/web/public/images/common/shield.svg diff --git a/apps/web/public/images/common/stake-illustration-dark.svg b/apps/web/public/images/common/stake-illustration-dark.svg new file mode 100644 index 000000000..e651e9c83 --- /dev/null +++ b/apps/web/public/images/common/stake-illustration-dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/images/common/stake-illustration-light.svg b/apps/web/public/images/common/stake-illustration-light.svg new file mode 100644 index 000000000..d32614efe --- /dev/null +++ b/apps/web/public/images/common/stake-illustration-light.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/images/common/stake.svg b/apps/web/public/images/common/stake.svg new file mode 100644 index 000000000..41469d1e8 --- /dev/null +++ b/apps/web/public/images/common/stake.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/common/success.svg b/apps/web/public/images/common/success.svg similarity index 100% rename from public/images/common/success.svg rename to apps/web/public/images/common/success.svg diff --git a/public/images/common/swap-empty-dark.svg b/apps/web/public/images/common/swap-empty-dark.svg similarity index 100% rename from public/images/common/swap-empty-dark.svg rename to apps/web/public/images/common/swap-empty-dark.svg diff --git a/public/images/common/swap-empty-light.svg b/apps/web/public/images/common/swap-empty-light.svg similarity index 100% rename from public/images/common/swap-empty-light.svg rename to apps/web/public/images/common/swap-empty-light.svg diff --git a/public/images/common/swap.svg b/apps/web/public/images/common/swap.svg similarity index 100% rename from public/images/common/swap.svg rename to apps/web/public/images/common/swap.svg diff --git a/public/images/common/token-placeholder.svg b/apps/web/public/images/common/token-placeholder.svg similarity index 100% rename from public/images/common/token-placeholder.svg rename to apps/web/public/images/common/token-placeholder.svg diff --git a/public/images/common/tx-failed.svg b/apps/web/public/images/common/tx-failed.svg similarity index 100% rename from public/images/common/tx-failed.svg rename to apps/web/public/images/common/tx-failed.svg diff --git a/public/images/common/walletconnect.svg b/apps/web/public/images/common/walletconnect.svg similarity index 100% rename from public/images/common/walletconnect.svg rename to apps/web/public/images/common/walletconnect.svg diff --git a/apps/web/public/images/common/zkemail-logo.svg b/apps/web/public/images/common/zkemail-logo.svg new file mode 100644 index 000000000..8b30a8693 --- /dev/null +++ b/apps/web/public/images/common/zkemail-logo.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/public/images/logo-no-text.svg b/apps/web/public/images/logo-no-text.svg similarity index 100% rename from public/images/logo-no-text.svg rename to apps/web/public/images/logo-no-text.svg diff --git a/public/images/logo-round.svg b/apps/web/public/images/logo-round.svg similarity index 100% rename from public/images/logo-round.svg rename to apps/web/public/images/logo-round.svg diff --git a/public/images/logo-text.svg b/apps/web/public/images/logo-text.svg similarity index 100% rename from public/images/logo-text.svg rename to apps/web/public/images/logo-text.svg diff --git a/public/images/logo.svg b/apps/web/public/images/logo.svg similarity index 100% rename from public/images/logo.svg rename to apps/web/public/images/logo.svg diff --git a/public/images/messages/created.svg b/apps/web/public/images/messages/created.svg similarity index 100% rename from public/images/messages/created.svg rename to apps/web/public/images/messages/created.svg diff --git a/public/images/messages/dot.svg b/apps/web/public/images/messages/dot.svg similarity index 100% rename from public/images/messages/dot.svg rename to apps/web/public/images/messages/dot.svg diff --git a/apps/web/public/images/messages/link.svg b/apps/web/public/images/messages/link.svg new file mode 100644 index 000000000..e8fd9ac33 --- /dev/null +++ b/apps/web/public/images/messages/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/messages/no-messages.svg b/apps/web/public/images/messages/no-messages.svg similarity index 100% rename from public/images/messages/no-messages.svg rename to apps/web/public/images/messages/no-messages.svg diff --git a/public/images/messages/required.svg b/apps/web/public/images/messages/required.svg similarity index 100% rename from public/images/messages/required.svg rename to apps/web/public/images/messages/required.svg diff --git a/public/images/messages/signed.svg b/apps/web/public/images/messages/signed.svg similarity index 100% rename from public/images/messages/signed.svg rename to apps/web/public/images/messages/signed.svg diff --git a/public/images/networks/rsk.png b/apps/web/public/images/networks/rsk.png similarity index 100% rename from public/images/networks/rsk.png rename to apps/web/public/images/networks/rsk.png diff --git a/public/images/networks/trsk.png b/apps/web/public/images/networks/trsk.png similarity index 100% rename from public/images/networks/trsk.png rename to apps/web/public/images/networks/trsk.png diff --git a/public/images/notifications/alert.svg b/apps/web/public/images/notifications/alert.svg similarity index 100% rename from public/images/notifications/alert.svg rename to apps/web/public/images/notifications/alert.svg diff --git a/public/images/notifications/error.svg b/apps/web/public/images/notifications/error.svg similarity index 100% rename from public/images/notifications/error.svg rename to apps/web/public/images/notifications/error.svg diff --git a/public/images/notifications/info.svg b/apps/web/public/images/notifications/info.svg similarity index 100% rename from public/images/notifications/info.svg rename to apps/web/public/images/notifications/info.svg diff --git a/public/images/notifications/no-notifications.svg b/apps/web/public/images/notifications/no-notifications.svg similarity index 100% rename from public/images/notifications/no-notifications.svg rename to apps/web/public/images/notifications/no-notifications.svg diff --git a/public/images/notifications/push-notification.svg b/apps/web/public/images/notifications/push-notification.svg similarity index 100% rename from public/images/notifications/push-notification.svg rename to apps/web/public/images/notifications/push-notification.svg diff --git a/public/images/notifications/success.svg b/apps/web/public/images/notifications/success.svg similarity index 100% rename from public/images/notifications/success.svg rename to apps/web/public/images/notifications/success.svg diff --git a/public/images/notifications/warning.svg b/apps/web/public/images/notifications/warning.svg similarity index 100% rename from public/images/notifications/warning.svg rename to apps/web/public/images/notifications/warning.svg diff --git a/public/images/open/safe-creation-error.svg b/apps/web/public/images/open/safe-creation-error.svg similarity index 100% rename from public/images/open/safe-creation-error.svg rename to apps/web/public/images/open/safe-creation-error.svg diff --git a/public/images/open/safe-creation-process.gif b/apps/web/public/images/open/safe-creation-process.gif similarity index 100% rename from public/images/open/safe-creation-process.gif rename to apps/web/public/images/open/safe-creation-process.gif diff --git a/public/images/open/safe-creation.svg b/apps/web/public/images/open/safe-creation.svg similarity index 100% rename from public/images/open/safe-creation.svg rename to apps/web/public/images/open/safe-creation.svg diff --git a/apps/web/public/images/protofire-logo.svg b/apps/web/public/images/protofire-logo.svg new file mode 100644 index 000000000..928d8dbaa --- /dev/null +++ b/apps/web/public/images/protofire-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/safe-logo-green.png b/apps/web/public/images/safe-logo-green.png similarity index 100% rename from public/images/safe-logo-green.png rename to apps/web/public/images/safe-logo-green.png diff --git a/public/images/settings/data/file.svg b/apps/web/public/images/settings/data/file.svg similarity index 100% rename from public/images/settings/data/file.svg rename to apps/web/public/images/settings/data/file.svg diff --git a/public/images/settings/permissions/shield.svg b/apps/web/public/images/settings/permissions/shield.svg similarity index 100% rename from public/images/settings/permissions/shield.svg rename to apps/web/public/images/settings/permissions/shield.svg diff --git a/public/images/settings/setup/replace-owner.svg b/apps/web/public/images/settings/setup/replace-owner.svg similarity index 100% rename from public/images/settings/setup/replace-owner.svg rename to apps/web/public/images/settings/setup/replace-owner.svg diff --git a/public/images/settings/spending-limit/asset-amount.svg b/apps/web/public/images/settings/spending-limit/asset-amount.svg similarity index 100% rename from public/images/settings/spending-limit/asset-amount.svg rename to apps/web/public/images/settings/spending-limit/asset-amount.svg diff --git a/public/images/settings/spending-limit/beneficiary.svg b/apps/web/public/images/settings/spending-limit/beneficiary.svg similarity index 100% rename from public/images/settings/spending-limit/beneficiary.svg rename to apps/web/public/images/settings/spending-limit/beneficiary.svg diff --git a/public/images/settings/spending-limit/speed.svg b/apps/web/public/images/settings/spending-limit/speed.svg similarity index 100% rename from public/images/settings/spending-limit/speed.svg rename to apps/web/public/images/settings/spending-limit/speed.svg diff --git a/public/images/settings/spending-limit/time.svg b/apps/web/public/images/settings/spending-limit/time.svg similarity index 100% rename from public/images/settings/spending-limit/time.svg rename to apps/web/public/images/settings/spending-limit/time.svg diff --git a/public/images/sidebar/address-book.svg b/apps/web/public/images/sidebar/address-book.svg similarity index 100% rename from public/images/sidebar/address-book.svg rename to apps/web/public/images/sidebar/address-book.svg diff --git a/public/images/sidebar/apps.svg b/apps/web/public/images/sidebar/apps.svg similarity index 100% rename from public/images/sidebar/apps.svg rename to apps/web/public/images/sidebar/apps.svg diff --git a/public/images/sidebar/assets.svg b/apps/web/public/images/sidebar/assets.svg similarity index 100% rename from public/images/sidebar/assets.svg rename to apps/web/public/images/sidebar/assets.svg diff --git a/public/images/sidebar/copy-bold.svg b/apps/web/public/images/sidebar/copy-bold.svg similarity index 100% rename from public/images/sidebar/copy-bold.svg rename to apps/web/public/images/sidebar/copy-bold.svg diff --git a/public/images/sidebar/help-center.svg b/apps/web/public/images/sidebar/help-center.svg similarity index 100% rename from public/images/sidebar/help-center.svg rename to apps/web/public/images/sidebar/help-center.svg diff --git a/public/images/sidebar/home.svg b/apps/web/public/images/sidebar/home.svg similarity index 100% rename from public/images/sidebar/home.svg rename to apps/web/public/images/sidebar/home.svg diff --git a/apps/web/public/images/sidebar/lightbulb_icon.svg b/apps/web/public/images/sidebar/lightbulb_icon.svg new file mode 100644 index 000000000..717dd8c3e --- /dev/null +++ b/apps/web/public/images/sidebar/lightbulb_icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/sidebar/link-bold.svg b/apps/web/public/images/sidebar/link-bold.svg similarity index 100% rename from public/images/sidebar/link-bold.svg rename to apps/web/public/images/sidebar/link-bold.svg diff --git a/public/images/sidebar/link.svg b/apps/web/public/images/sidebar/link.svg similarity index 100% rename from public/images/sidebar/link.svg rename to apps/web/public/images/sidebar/link.svg diff --git a/apps/web/public/images/sidebar/multichain-account.svg b/apps/web/public/images/sidebar/multichain-account.svg new file mode 100644 index 000000000..bb6ebeafd --- /dev/null +++ b/apps/web/public/images/sidebar/multichain-account.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/sidebar/qr-bold.svg b/apps/web/public/images/sidebar/qr-bold.svg similarity index 100% rename from public/images/sidebar/qr-bold.svg rename to apps/web/public/images/sidebar/qr-bold.svg diff --git a/public/images/sidebar/settings.svg b/apps/web/public/images/sidebar/settings.svg similarity index 100% rename from public/images/sidebar/settings.svg rename to apps/web/public/images/sidebar/settings.svg diff --git a/public/images/sidebar/transactions.svg b/apps/web/public/images/sidebar/transactions.svg similarity index 100% rename from public/images/sidebar/transactions.svg rename to apps/web/public/images/sidebar/transactions.svg diff --git a/public/images/sidebar/whats-new.svg b/apps/web/public/images/sidebar/whats-new.svg similarity index 100% rename from public/images/sidebar/whats-new.svg rename to apps/web/public/images/sidebar/whats-new.svg diff --git a/public/images/social-share.png b/apps/web/public/images/social-share.png similarity index 100% rename from public/images/social-share.png rename to apps/web/public/images/social-share.png diff --git a/apps/web/public/images/transactions/blockaid-icon.svg b/apps/web/public/images/transactions/blockaid-icon.svg new file mode 100644 index 000000000..aade85804 --- /dev/null +++ b/apps/web/public/images/transactions/blockaid-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/transactions/circle-cross-red.svg b/apps/web/public/images/transactions/circle-cross-red.svg similarity index 100% rename from public/images/transactions/circle-cross-red.svg rename to apps/web/public/images/transactions/circle-cross-red.svg diff --git a/public/images/transactions/custom.svg b/apps/web/public/images/transactions/custom.svg similarity index 100% rename from public/images/transactions/custom.svg rename to apps/web/public/images/transactions/custom.svg diff --git a/public/images/transactions/ghost.svg b/apps/web/public/images/transactions/ghost.svg similarity index 100% rename from public/images/transactions/ghost.svg rename to apps/web/public/images/transactions/ghost.svg diff --git a/public/images/transactions/incoming.svg b/apps/web/public/images/transactions/incoming.svg similarity index 100% rename from public/images/transactions/incoming.svg rename to apps/web/public/images/transactions/incoming.svg diff --git a/apps/web/public/images/transactions/nestedTx.svg b/apps/web/public/images/transactions/nestedTx.svg new file mode 100644 index 000000000..fa50e1633 --- /dev/null +++ b/apps/web/public/images/transactions/nestedTx.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/images/transactions/new-tx.svg b/apps/web/public/images/transactions/new-tx.svg similarity index 100% rename from public/images/transactions/new-tx.svg rename to apps/web/public/images/transactions/new-tx.svg diff --git a/public/images/transactions/no-transactions.svg b/apps/web/public/images/transactions/no-transactions.svg similarity index 100% rename from public/images/transactions/no-transactions.svg rename to apps/web/public/images/transactions/no-transactions.svg diff --git a/public/images/transactions/outgoing.svg b/apps/web/public/images/transactions/outgoing.svg similarity index 100% rename from public/images/transactions/outgoing.svg rename to apps/web/public/images/transactions/outgoing.svg diff --git a/public/images/transactions/recovery-execution.svg b/apps/web/public/images/transactions/recovery-execution.svg similarity index 100% rename from public/images/transactions/recovery-execution.svg rename to apps/web/public/images/transactions/recovery-execution.svg diff --git a/public/images/transactions/recovery-recoverer.svg b/apps/web/public/images/transactions/recovery-recoverer.svg similarity index 100% rename from public/images/transactions/recovery-recoverer.svg rename to apps/web/public/images/transactions/recovery-recoverer.svg diff --git a/public/images/transactions/redefine-dark-mode.png b/apps/web/public/images/transactions/redefine-dark-mode.png similarity index 100% rename from public/images/transactions/redefine-dark-mode.png rename to apps/web/public/images/transactions/redefine-dark-mode.png diff --git a/public/images/transactions/redefine.png b/apps/web/public/images/transactions/redefine.png similarity index 100% rename from public/images/transactions/redefine.png rename to apps/web/public/images/transactions/redefine.png diff --git a/public/images/transactions/replace-tx.svg b/apps/web/public/images/transactions/replace-tx.svg similarity index 100% rename from public/images/transactions/replace-tx.svg rename to apps/web/public/images/transactions/replace-tx.svg diff --git a/public/images/transactions/rocket.svg b/apps/web/public/images/transactions/rocket.svg similarity index 100% rename from public/images/transactions/rocket.svg rename to apps/web/public/images/transactions/rocket.svg diff --git a/public/images/transactions/settings.svg b/apps/web/public/images/transactions/settings.svg similarity index 100% rename from public/images/transactions/settings.svg rename to apps/web/public/images/transactions/settings.svg diff --git a/apps/web/public/images/transactions/signature.svg b/apps/web/public/images/transactions/signature.svg new file mode 100644 index 000000000..79aa58d5a --- /dev/null +++ b/apps/web/public/images/transactions/signature.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/transactions/tenderly-dark.svg b/apps/web/public/images/transactions/tenderly-dark.svg similarity index 100% rename from public/images/transactions/tenderly-dark.svg rename to apps/web/public/images/transactions/tenderly-dark.svg diff --git a/public/images/transactions/tenderly-light.svg b/apps/web/public/images/transactions/tenderly-light.svg similarity index 100% rename from public/images/transactions/tenderly-light.svg rename to apps/web/public/images/transactions/tenderly-light.svg diff --git a/public/images/transactions/transactions.svg b/apps/web/public/images/transactions/transactions.svg similarity index 100% rename from public/images/transactions/transactions.svg rename to apps/web/public/images/transactions/transactions.svg diff --git a/apps/web/public/images/transactions/zodiac-roles.svg b/apps/web/public/images/transactions/zodiac-roles.svg new file mode 100644 index 000000000..5eeb888df --- /dev/null +++ b/apps/web/public/images/transactions/zodiac-roles.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/welcome/load-safe.svg b/apps/web/public/images/welcome/load-safe.svg similarity index 100% rename from public/images/welcome/load-safe.svg rename to apps/web/public/images/welcome/load-safe.svg diff --git a/public/images/welcome/logo-google.svg b/apps/web/public/images/welcome/logo-google.svg similarity index 100% rename from public/images/welcome/logo-google.svg rename to apps/web/public/images/welcome/logo-google.svg diff --git a/public/images/welcome/new-safe.svg b/apps/web/public/images/welcome/new-safe.svg similarity index 100% rename from public/images/welcome/new-safe.svg rename to apps/web/public/images/welcome/new-safe.svg diff --git a/public/safe.webmanifest b/apps/web/public/safe.webmanifest similarity index 100% rename from public/safe.webmanifest rename to apps/web/public/safe.webmanifest diff --git a/scripts/cmp.sh b/apps/web/scripts/cmp.sh similarity index 100% rename from scripts/cmp.sh rename to apps/web/scripts/cmp.sh diff --git a/scripts/css-vars.ts b/apps/web/scripts/css-vars.ts similarity index 100% rename from scripts/css-vars.ts rename to apps/web/scripts/css-vars.ts diff --git a/scripts/generate-routes.js b/apps/web/scripts/generate-routes.js similarity index 100% rename from scripts/generate-routes.js rename to apps/web/scripts/generate-routes.js diff --git a/scripts/github/download_bundle_analyser_artifact.sh b/apps/web/scripts/github/download_bundle_analyser_artifact.sh similarity index 100% rename from scripts/github/download_bundle_analyser_artifact.sh rename to apps/web/scripts/github/download_bundle_analyser_artifact.sh diff --git a/scripts/github/prepare_production_deployment.sh b/apps/web/scripts/github/prepare_production_deployment.sh similarity index 100% rename from scripts/github/prepare_production_deployment.sh rename to apps/web/scripts/github/prepare_production_deployment.sh diff --git a/scripts/github/s3_upload.sh b/apps/web/scripts/github/s3_upload.sh similarity index 96% rename from scripts/github/s3_upload.sh rename to apps/web/scripts/github/s3_upload.sh index 048cf1438..effa8204f 100755 --- a/scripts/github/s3_upload.sh +++ b/apps/web/scripts/github/s3_upload.sh @@ -13,7 +13,9 @@ aws s3 sync . $BUCKET --delete # 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' + aws s3 cp ${file%} $BUCKET/${file%.*} --content-type 'text/html' & done +wait + cd - diff --git a/apps/web/src/components/address-book/AddressBookHeader/index.tsx b/apps/web/src/components/address-book/AddressBookHeader/index.tsx new file mode 100644 index 000000000..13e389dbb --- /dev/null +++ b/apps/web/src/components/address-book/AddressBookHeader/index.tsx @@ -0,0 +1,124 @@ +import { Button, SvgIcon, Grid } from '@mui/material' +import type { ReactElement, ElementType } from 'react' +import InputAdornment from '@mui/material/InputAdornment' +import SearchIcon from '@/public/images/common/search.svg' +import TextField from '@mui/material/TextField' + +import Track from '@/components/common/Track' +import { ADDRESS_BOOK_EVENTS } from '@/services/analytics/events/addressBook' +import PageHeader from '@/components/common/PageHeader' +import { ModalType } from '../AddressBookTable' +import { useAppSelector } from '@/store' +import { type AddressBookState, selectAllAddressBooks } from '@/store/addressBookSlice' +import ImportIcon from '@/public/images/common/import.svg' +import ExportIcon from '@/public/images/common/export.svg' +import AddCircleIcon from '@/public/images/common/add-outlined.svg' +import mapProps from '@/utils/mad-props' + +const HeaderButton = ({ + icon, + onClick, + disabled, + children, +}: { + icon: ElementType + onClick: () => void + disabled?: boolean + children: string +}): ReactElement => { + const svg = + + return ( + + ) +} + +type Props = { + allAddressBooks: AddressBookState + handleOpenModal: (type: ModalType) => () => void + searchQuery: string + onSearchQueryChange: (searchQuery: string) => void +} + +function AddressBookHeader({ + allAddressBooks, + handleOpenModal, + searchQuery, + onSearchQueryChange, +}: Props): ReactElement { + const canExport = Object.values(allAddressBooks).some((addressBook) => Object.keys(addressBook || {}).length > 0) + + return ( + + + { + onSearchQueryChange(e.target.value) + }} + InputProps={{ + startAdornment: ( + + + + ), + disableUnderline: true, + }} + fullWidth + size="small" + /> + + + + + Import + + + + + + Export + + + + + + Create entry + + + + + } + /> + ) +} + +const useAllAddressBooks = () => useAppSelector(selectAllAddressBooks) + +export default mapProps(AddressBookHeader, { + allAddressBooks: useAllAddressBooks, +}) diff --git a/src/components/address-book/AddressBookTable/index.tsx b/apps/web/src/components/address-book/AddressBookTable/index.tsx similarity index 100% rename from src/components/address-book/AddressBookTable/index.tsx rename to apps/web/src/components/address-book/AddressBookTable/index.tsx diff --git a/src/components/address-book/AddressBookTable/styles.module.css b/apps/web/src/components/address-book/AddressBookTable/styles.module.css similarity index 100% rename from src/components/address-book/AddressBookTable/styles.module.css rename to apps/web/src/components/address-book/AddressBookTable/styles.module.css diff --git a/apps/web/src/components/address-book/EntryDialog/index.tsx b/apps/web/src/components/address-book/EntryDialog/index.tsx new file mode 100644 index 000000000..3c809ec4c --- /dev/null +++ b/apps/web/src/components/address-book/EntryDialog/index.tsx @@ -0,0 +1,103 @@ +import type { ReactElement, BaseSyntheticEvent } from 'react' +import { Box, Button, DialogActions, DialogContent } from '@mui/material' +import { FormProvider, useForm } from 'react-hook-form' + +import AddressInput from '@/components/common/AddressInput' +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' + +export type AddressEntry = { + name: string + address: string +} + +function EntryDialog({ + handleClose, + defaultValues = { + name: '', + address: '', + }, + disableAddressInput = false, + chainIds, + currentChainId, +}: { + handleClose: () => void + defaultValues?: AddressEntry + disableAddressInput?: boolean + chainIds?: string[] + currentChainId: string +}): ReactElement { + const dispatch = useAppDispatch() + + const methods = useForm({ + defaultValues, + mode: 'onChange', + }) + + const { handleSubmit, formState } = methods + + const submitCallback = handleSubmit((data: AddressEntry) => { + dispatch(upsertAddressBookEntries({ ...data, chainIds: chainIds ?? [currentChainId] })) + handleClose() + }) + + const onSubmit = (e: BaseSyntheticEvent) => { + e.stopPropagation() + submitCallback(e) + } + + return ( + 1} + chainId={chainIds?.[0]} + > + +
+ + + + + + + + + + + + + + +
+
+
+ ) +} + +export default madProps(EntryDialog, { + currentChainId: useChainId, +}) diff --git a/src/components/address-book/ExportDialog/index.test.tsx b/apps/web/src/components/address-book/ExportDialog/index.test.tsx similarity index 100% rename from src/components/address-book/ExportDialog/index.test.tsx rename to apps/web/src/components/address-book/ExportDialog/index.test.tsx diff --git a/src/components/address-book/ExportDialog/index.tsx b/apps/web/src/components/address-book/ExportDialog/index.tsx similarity index 100% rename from src/components/address-book/ExportDialog/index.tsx rename to apps/web/src/components/address-book/ExportDialog/index.tsx diff --git a/src/components/address-book/ImportDialog/__tests__/validation.test.ts b/apps/web/src/components/address-book/ImportDialog/__tests__/validation.test.ts similarity index 100% rename from src/components/address-book/ImportDialog/__tests__/validation.test.ts rename to apps/web/src/components/address-book/ImportDialog/__tests__/validation.test.ts diff --git a/apps/web/src/components/address-book/ImportDialog/index.tsx b/apps/web/src/components/address-book/ImportDialog/index.tsx new file mode 100644 index 000000000..8f7e47b39 --- /dev/null +++ b/apps/web/src/components/address-book/ImportDialog/index.tsx @@ -0,0 +1,183 @@ +import DialogContent from '@mui/material/DialogContent' +import DialogActions from '@mui/material/DialogActions' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import { useCSVReader, formatFileSize } from 'react-papaparse' +import type { ParseResult } from 'papaparse' +import { type ReactElement, useState, type MouseEvent, useMemo } from 'react' + +import ModalDialog from '@/components/common/ModalDialog' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' +import { useAppDispatch } from '@/store' + +import css from './styles.module.css' +import { trackEvent, ADDRESS_BOOK_EVENTS } from '@/services/analytics' +import { abCsvReaderValidator, abOnUploadValidator } from './validation' +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' + +type AddressBookCSVRow = ['address', 'name', 'chainId'] + +// https://react-papaparse.js.org/docs#errors +type PapaparseErrorType = { + type: 'Quotes' | 'Delimiter' | 'FieldMismatch' + code: 'MissingQuotes' | 'UndetectableDelimiter' | 'TooFewFields' | 'TooManyFields' + message: string + row?: number + index?: number +} + +const hasEntry = (entry: string[]) => { + return entry.length === 3 && entry[0] && entry[1] && entry[2] +} + +const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElement => { + const [zoneHover, setZoneHover] = useState(false) + const [csvData, setCsvData] = useState>() + const [error, setError] = useState() + + // Count how many entries are in the CSV file + const [entryCount, chainCount] = useMemo(() => { + if (!csvData) return [0, 0] + const entries = csvData.data.slice(1).filter(hasEntry) + const entryLen = entries.length + const chainLen = new Set(entries.map((entry) => entry[2].trim())).size + return [entryLen, chainLen] + }, [csvData]) + + const dispatch = useAppDispatch() + const { CSVReader } = useCSVReader() + + const handleImport = () => { + if (!csvData) { + return + } + + const [, ...entries] = csvData.data + + for (const entry of entries) { + const [address, name, chainId] = entry + dispatch(upsertAddressBookEntries({ address, name, chainIds: [chainId.trim()] })) + } + + trackEvent({ ...ADDRESS_BOOK_EVENTS.IMPORT, label: entries.length }) + + handleClose() + } + + return ( + + + { + setZoneHover(true) + }} + onDragLeave={() => { + setZoneHover(false) + }} + validator={abCsvReaderValidator} + onUploadRejected={(result: { file: File; errors?: Array }[]) => { + setZoneHover(false) + setError(undefined) + + // csvReaderValidator error + const error = result?.[0].errors?.pop() + + if (error) { + const errorDescription = typeof error === 'string' ? error.toString() : error.message + setError(errorDescription) + logError(Errors._703, errorDescription) + } + }} + onUploadAccepted={(result: ParseResult<['address', 'name', 'chainId']>) => { + setZoneHover(false) + setError(undefined) + + // Remove empty rows + const cleanResult = { + ...result, + data: result.data.filter(hasEntry), + } + + const message = abOnUploadValidator(cleanResult) + + if (message) { + setError(message) + } else { + setCsvData(cleanResult) + } + }} + > + {/* https://github.com/Bunlong/react-papaparse/blob/master/src/useCSVReader.tsx */} + {({ getRootProps, acceptedFile, getRemoveFileProps }: any) => { + const { onClick } = getRemoveFileProps() + + const onRemove = (e: MouseEvent) => { + setCsvData(undefined) + setError(undefined) + onClick(e) + } + + const fileInfo: FileInfo | undefined = acceptedFile + ? { + name: acceptedFile.name, + additionalInfo: formatFileSize(acceptedFile.size), + summary: [ + + {`Found ${entryCount} entries on ${chainCount} ${chainCount > 1 ? 'chains' : 'chain'}`} + , + ], + } + : undefined + + return ( + + ) + }} + + +
+ + {error && {error}} + + + Only CSV files exported from a {BRAND_NAME} can be imported. +
+ + Learn about the address book import and export + +
+ + + + + + + ) +} + +export default ImportDialog diff --git a/src/components/address-book/ImportDialog/styles.module.css b/apps/web/src/components/address-book/ImportDialog/styles.module.css similarity index 100% rename from src/components/address-book/ImportDialog/styles.module.css rename to apps/web/src/components/address-book/ImportDialog/styles.module.css diff --git a/src/components/address-book/ImportDialog/validation.ts b/apps/web/src/components/address-book/ImportDialog/validation.ts similarity index 100% rename from src/components/address-book/ImportDialog/validation.ts rename to apps/web/src/components/address-book/ImportDialog/validation.ts diff --git a/src/components/address-book/RemoveDialog/index.tsx b/apps/web/src/components/address-book/RemoveDialog/index.tsx similarity index 100% rename from src/components/address-book/RemoveDialog/index.tsx rename to apps/web/src/components/address-book/RemoveDialog/index.tsx diff --git a/src/components/balances/AssetsHeader/index.tsx b/apps/web/src/components/balances/AssetsHeader/index.tsx similarity index 100% rename from src/components/balances/AssetsHeader/index.tsx rename to apps/web/src/components/balances/AssetsHeader/index.tsx diff --git a/src/components/balances/AssetsTable/SendButton.tsx b/apps/web/src/components/balances/AssetsTable/SendButton.tsx similarity index 100% rename from src/components/balances/AssetsTable/SendButton.tsx rename to apps/web/src/components/balances/AssetsTable/SendButton.tsx diff --git a/src/components/balances/AssetsTable/index.test.tsx b/apps/web/src/components/balances/AssetsTable/index.test.tsx similarity index 100% rename from src/components/balances/AssetsTable/index.test.tsx rename to apps/web/src/components/balances/AssetsTable/index.test.tsx diff --git a/apps/web/src/components/balances/AssetsTable/index.tsx b/apps/web/src/components/balances/AssetsTable/index.tsx new file mode 100644 index 000000000..50d75aecd --- /dev/null +++ b/apps/web/src/components/balances/AssetsTable/index.tsx @@ -0,0 +1,242 @@ +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 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' +import { useHideAssets, useVisibleAssets } from './useHideAssets' +import AddFundsCTA from '@/components/common/AddFunds' +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 { STAKE_LABELS } from '@/services/analytics/events/stake' +import StakeButton from '@/features/stake/components/StakeButton' + +const skeletonCells: EnhancedTableProps['rows'][0]['cells'] = { + asset: { + rawValue: '0x0', + content: ( +
+ + + + +
+ ), + }, + balance: { + rawValue: '0', + content: ( + + + + ), + }, + value: { + rawValue: '0', + content: ( + + + + ), + }, + actions: { + rawValue: '', + sticky: true, + content:
, + }, +} + +const skeletonRows: EnhancedTableProps['rows'] = Array(3).fill({ cells: skeletonCells }) + +const isNativeToken = (tokenInfo: TokenInfo) => { + return tokenInfo.type === TokenType.NATIVE_TOKEN +} + +const headCells = [ + { + id: 'asset', + label: 'Asset', + width: '60%', + }, + { + id: 'balance', + label: 'Balance', + width: '20%', + }, + { + id: 'value', + label: 'Value', + width: '20%', + align: 'right', + }, + { + id: 'actions', + label: '', + width: '20%', + sticky: true, + }, +] + +const AssetsTable = ({ + showHiddenAssets, + setShowHiddenAssets, +}: { + showHiddenAssets: boolean + setShowHiddenAssets: (hidden: boolean) => void +}): ReactElement => { + const { balances, loading } = useBalances() + const isSwapFeatureEnabled = useIsSwapFeatureEnabled() + const isStakingFeatureEnabled = useIsStakingFeatureEnabled() + + const { isAssetSelected, toggleAsset, hidingAsset, hideAsset, cancel, deselectAll, saveChanges } = useHideAssets(() => + setShowHiddenAssets(false), + ) + + const visible = useVisibleAssets() + const visibleAssets = showHiddenAssets ? balances.items : visible + + const hasNoAssets = !loading && balances.items.length === 1 && balances.items[0].balance === '0' + + const selectedAssetCount = visibleAssets?.filter((item) => isAssetSelected(item.tokenInfo.address)).length || 0 + + const rows = loading + ? skeletonRows + : (visibleAssets || []).map((item) => { + const rawFiatValue = parseFloat(item.fiatBalance) + const isNative = isNativeToken(item.tokenInfo) + const isSelected = isAssetSelected(item.tokenInfo.address) + + return { + key: item.tokenInfo.address, + selected: isSelected, + collapsed: item.tokenInfo.address === hidingAsset, + cells: { + asset: { + rawValue: item.tokenInfo.name, + collapsed: item.tokenInfo.address === hidingAsset, + content: ( +
+ + + {item.tokenInfo.name} + + {isStakingFeatureEnabled && item.tokenInfo.type === TokenType.NATIVE_TOKEN && ( + + )} + + {!isNative && } +
+ ), + }, + balance: { + rawValue: Number(item.balance) / 10 ** item.tokenInfo.decimals, + collapsed: item.tokenInfo.address === hidingAsset, + content: ( + + ), + }, + value: { + rawValue: rawFiatValue, + collapsed: item.tokenInfo.address === hidingAsset, + content: ( + + + + {rawFiatValue === 0 && ( + + + + + + )} + + ), + }, + actions: { + rawValue: '', + sticky: true, + collapsed: item.tokenInfo.address === hidingAsset, + content: ( + + <> + + + {isSwapFeatureEnabled && ( + + )} + + {showHiddenAssets ? ( + toggleAsset(item.tokenInfo.address)} /> + ) : ( + + + hideAsset(item.tokenInfo.address)} + > + + + + + )} + + + ), + }, + }, + } + }) + + return ( + <> + + + {hasNoAssets ? ( + + ) : ( +
+ +
+ )} + + + + ) +} + +export default AssetsTable diff --git a/src/components/balances/AssetsTable/styles.module.css b/apps/web/src/components/balances/AssetsTable/styles.module.css similarity index 100% rename from src/components/balances/AssetsTable/styles.module.css rename to apps/web/src/components/balances/AssetsTable/styles.module.css diff --git a/src/components/balances/AssetsTable/useHideAssets.ts b/apps/web/src/components/balances/AssetsTable/useHideAssets.ts similarity index 100% rename from src/components/balances/AssetsTable/useHideAssets.ts rename to apps/web/src/components/balances/AssetsTable/useHideAssets.ts diff --git a/apps/web/src/components/balances/CurrencySelect/__tests__/index.test.tsx b/apps/web/src/components/balances/CurrencySelect/__tests__/index.test.tsx new file mode 100644 index 000000000..c6c9ff29f --- /dev/null +++ b/apps/web/src/components/balances/CurrencySelect/__tests__/index.test.tsx @@ -0,0 +1,20 @@ +import { renderWithUserEvent } from '@/tests/test-utils' +import CurrencySelect from '@/components/balances/CurrencySelect' + +describe('useCurrencies', () => { + it('Should render the fetched', async () => { + const { user, getByRole, findAllByTestId, getByLabelText } = renderWithUserEvent() + const select = getByRole('combobox') + + expect(getByLabelText('USD')).toBeTruthy() + + await user.click(select) + + const menuItems = await findAllByTestId('currency-item') + + expect(menuItems.length).toBe(3) + expect(menuItems[0]).toHaveTextContent('USD') + expect(menuItems[1]).toHaveTextContent('EUR') + expect(menuItems[2]).toHaveTextContent('GBP') + }) +}) diff --git a/src/components/balances/CurrencySelect/index.tsx b/apps/web/src/components/balances/CurrencySelect/index.tsx similarity index 100% rename from src/components/balances/CurrencySelect/index.tsx rename to apps/web/src/components/balances/CurrencySelect/index.tsx diff --git a/apps/web/src/components/balances/CurrencySelect/useCurrencies.ts b/apps/web/src/components/balances/CurrencySelect/useCurrencies.ts new file mode 100644 index 000000000..d0a413124 --- /dev/null +++ b/apps/web/src/components/balances/CurrencySelect/useCurrencies.ts @@ -0,0 +1,11 @@ +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/src/components/balances/HiddenTokenButton/index.test.tsx b/apps/web/src/components/balances/HiddenTokenButton/index.test.tsx similarity index 100% rename from src/components/balances/HiddenTokenButton/index.test.tsx rename to apps/web/src/components/balances/HiddenTokenButton/index.test.tsx diff --git a/apps/web/src/components/balances/HiddenTokenButton/index.tsx b/apps/web/src/components/balances/HiddenTokenButton/index.tsx new file mode 100644 index 000000000..df9593c36 --- /dev/null +++ b/apps/web/src/components/balances/HiddenTokenButton/index.tsx @@ -0,0 +1,54 @@ +import { type ReactElement } from 'react' +import { Typography, Button } from '@mui/material' +import { ASSETS_EVENTS } from '@/services/analytics' +import useHiddenTokens from '@/hooks/useHiddenTokens' +import useBalances from '@/hooks/useBalances' +import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined' +import Track from '@/components/common/Track' + +import css from './styles.module.css' +import { maybePlural } from '@/utils/formatters' + +const HiddenTokenButton = ({ + toggleShowHiddenAssets, + showHiddenAssets, +}: { + toggleShowHiddenAssets?: () => void + showHiddenAssets?: boolean +}): ReactElement | null => { + const { balances } = useBalances() + const currentHiddenAssets = useHiddenTokens() + + const hiddenAssetCount = + balances.items?.filter((item) => currentHiddenAssets.includes(item.tokenInfo.address)).length || 0 + + return ( +
+ + + +
+ ) +} + +export default HiddenTokenButton diff --git a/src/components/balances/HiddenTokenButton/styles.module.css b/apps/web/src/components/balances/HiddenTokenButton/styles.module.css similarity index 100% rename from src/components/balances/HiddenTokenButton/styles.module.css rename to apps/web/src/components/balances/HiddenTokenButton/styles.module.css diff --git a/src/components/balances/TokenListSelect/index.tsx b/apps/web/src/components/balances/TokenListSelect/index.tsx similarity index 100% rename from src/components/balances/TokenListSelect/index.tsx rename to apps/web/src/components/balances/TokenListSelect/index.tsx diff --git a/src/components/balances/TokenMenu/index.tsx b/apps/web/src/components/balances/TokenMenu/index.tsx similarity index 100% rename from src/components/balances/TokenMenu/index.tsx rename to apps/web/src/components/balances/TokenMenu/index.tsx diff --git a/src/components/balances/TokenMenu/styles.module.css b/apps/web/src/components/balances/TokenMenu/styles.module.css similarity index 100% rename from src/components/balances/TokenMenu/styles.module.css rename to apps/web/src/components/balances/TokenMenu/styles.module.css diff --git a/src/components/batch/BatchIndicator/BatchTooltip.tsx b/apps/web/src/components/batch/BatchIndicator/BatchTooltip.tsx similarity index 100% rename from src/components/batch/BatchIndicator/BatchTooltip.tsx rename to apps/web/src/components/batch/BatchIndicator/BatchTooltip.tsx diff --git a/src/components/batch/BatchIndicator/index.tsx b/apps/web/src/components/batch/BatchIndicator/index.tsx similarity index 100% rename from src/components/batch/BatchIndicator/index.tsx rename to apps/web/src/components/batch/BatchIndicator/index.tsx diff --git a/src/components/batch/BatchSidebar/BatchTxItem.tsx b/apps/web/src/components/batch/BatchSidebar/BatchTxItem.tsx similarity index 87% rename from src/components/batch/BatchSidebar/BatchTxItem.tsx rename to apps/web/src/components/batch/BatchSidebar/BatchTxItem.tsx index 8ab81c8dc..e1d3d0856 100644 --- a/src/components/batch/BatchSidebar/BatchTxItem.tsx +++ b/apps/web/src/components/batch/BatchSidebar/BatchTxItem.tsx @@ -49,10 +49,18 @@ const BatchTxItem = ({ id, count, timestamp, txDetails, onDelete }: BatchTxItemP return (
{count}
- } className={css.accordion}> - + @@ -75,7 +83,13 @@ const BatchTxItem = ({ id, count, timestamp, txDetails, onDelete }: BatchTxItemP
- + {timestamp ? dateString(timestamp) : null} diff --git a/src/components/batch/BatchSidebar/BatchTxList.tsx b/apps/web/src/components/batch/BatchSidebar/BatchTxList.tsx similarity index 100% rename from src/components/batch/BatchSidebar/BatchTxList.tsx rename to apps/web/src/components/batch/BatchSidebar/BatchTxList.tsx diff --git a/src/components/batch/BatchSidebar/EmptyBatch.tsx b/apps/web/src/components/batch/BatchSidebar/EmptyBatch.tsx similarity index 100% rename from src/components/batch/BatchSidebar/EmptyBatch.tsx rename to apps/web/src/components/batch/BatchSidebar/EmptyBatch.tsx diff --git a/src/components/batch/BatchSidebar/index.tsx b/apps/web/src/components/batch/BatchSidebar/index.tsx similarity index 100% rename from src/components/batch/BatchSidebar/index.tsx rename to apps/web/src/components/batch/BatchSidebar/index.tsx diff --git a/src/components/batch/BatchSidebar/styles.module.css b/apps/web/src/components/batch/BatchSidebar/styles.module.css similarity index 100% rename from src/components/batch/BatchSidebar/styles.module.css rename to apps/web/src/components/batch/BatchSidebar/styles.module.css diff --git a/apps/web/src/components/common/AddFunds/index.tsx b/apps/web/src/components/common/AddFunds/index.tsx new file mode 100644 index 000000000..a5742489f --- /dev/null +++ b/apps/web/src/components/common/AddFunds/index.tsx @@ -0,0 +1,100 @@ +import { Box, FormControlLabel, Grid, Paper, Switch, Typography } from '@mui/material' +import EthHashInfo from '@/components/common/EthHashInfo' +import QRCode from '@/components/common/QRCode' +import { useCurrentChain } from '@/hooks/useChains' +import useSafeAddress from '@/hooks/useSafeAddress' +import { useAppDispatch, useAppSelector } from '@/store' +import { selectSettings, setQrShortName } from '@/store/settingsSlice' +import BuyCryptoButton from '@/components/common/BuyCryptoButton' + +const AddFundsCTA = () => { + const safeAddress = useSafeAddress() + const chain = useCurrentChain() + const dispatch = useAppDispatch() + const settings = useAppSelector(selectSettings) + const qrPrefix = settings.shortName.qr ? `${chain?.shortName}:` : '' + const qrCode = `${qrPrefix}${safeAddress}` + + return ( + + + +
+ + + +
+ + dispatch(setQrShortName(e.target.checked))} /> + } + label={<>QR code with chain prefix} + /> +
+ + + + Add funds to get started + + + + Add funds directly from your bank account or copy your address to send tokens from a different account. + + + + + + + + + + +
+
+ ) +} + +export default AddFundsCTA diff --git a/apps/web/src/components/common/AddressBookInput/index.test.tsx b/apps/web/src/components/common/AddressBookInput/index.test.tsx new file mode 100644 index 000000000..ee910e6dc --- /dev/null +++ b/apps/web/src/components/common/AddressBookInput/index.test.tsx @@ -0,0 +1,277 @@ +import { act } from 'react' +import { fireEvent, render, waitFor } from '@/tests/test-utils' +import { FormProvider, useForm } from 'react-hook-form' +import AddressBookInput from '.' +import type { AddressInputProps } from '../AddressInput' +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 type { AddressBook } from '@/store/addressBookSlice' + +// We use Rinkeby and chainId 4 here as this is our default url chain (see jest.setup.js) +const mockChain = chainBuilder() + .with({ features: [FEATURES.DOMAIN_LOOKUP] }) + .with({ chainId: '4' }) + .with({ shortName: 'rin' }) + .build() + +// mock useCurrentChain +jest.mock('@/hooks/useChains', () => ({ + ...jest.requireActual('@/hooks/useChains'), + useCurrentChain: jest.fn(() => mockChain), + __esModule: true, +})) + +// mock useNameResolver +jest.mock('@/components/common/AddressInput/useNameResolver', () => ({ + __esModule: true, + default: jest.fn((val: string) => ({ + address: val === 'zero.eth' ? '0x0000000000000000000000000000000000000000' : undefined, + resolverError: val === 'bogus.eth' ? new Error('Failed to resolve') : undefined, + resolving: false, + })), +})) + +const testId = 'recipientAutocomplete' +const TestForm = ({ + address, + validate, + canAdd, +}: { + address: string + validate?: AddressInputProps['validate'] + canAdd?: boolean +}) => { + const name = 'recipient' + + const methods = useForm<{ + [name]: string + }>({ + defaultValues: { + [name]: address, + }, + mode: 'all', + }) + + return ( + +
null)}> + + + +
+ ) +} + +const setup = ( + address: string, + initialAddressBook: AddressBook, + validate?: AddressInputProps['validate'], + canAdd?: boolean, +) => { + const utils = render(, { + initialReduxState: { + addressBook: { + [mockChain.chainId]: initialAddressBook, + }, + chains: { data: [mockChain], loading: false }, + }, + }) + const input = utils.getByLabelText('Recipient address', { exact: false }) + + return { + input: input as HTMLInputElement, + utils, + } +} + +describe('AddressBookInput', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + beforeEach(() => { + jest.clearAllMocks() + ;(useCurrentChain as jest.Mock).mockImplementation(() => mockChain) + }) + + it('should not open autocomplete without entries', () => { + const { input } = setup('', {}) + + expect(input).toHaveAttribute('aria-expanded', 'false') + + act(() => { + fireEvent.mouseDown(input) + }) + + expect(input).toHaveAttribute('aria-expanded', 'false') + }) + + it('should open autocomplete with entries', () => { + const { input } = setup('', { + [checksumAddress(faker.finance.ethereumAddress())]: 'Tim Testermann', + }) + + expect(input).toHaveAttribute('aria-expanded', 'false') + + act(() => { + fireEvent.mouseDown(input) + }) + + expect(input).toHaveAttribute('aria-expanded', 'true') + }) + + it('should allow to input and validate an address by typing an address', async () => { + const invalidAddress = checksumAddress(faker.finance.ethereumAddress()) + const validationError = 'You cannot use this address' + const validation = (value: string) => (value === invalidAddress ? validationError : undefined) + + const { input, utils } = setup( + '', + { + [checksumAddress(faker.finance.ethereumAddress())]: 'Tim Testermann', + }, + validation, + ) + + expect(input).toHaveAttribute('aria-expanded', 'false') + + act(() => { + fireEvent.mouseDown(input) + fireEvent.mouseUp(input) + }) + + act(() => { + fireEvent.change(input, { target: { value: invalidAddress } }) + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => expect(utils.getByLabelText(validationError, { exact: false })).toBeDefined()) + + const address = checksumAddress(faker.finance.ethereumAddress()) + + act(() => { + fireEvent.change(input, { target: { value: address } }) + jest.advanceTimersByTime(1000) + }) + + expect(input.value).toBe(address) + await waitFor(() => expect(utils.queryByLabelText(validationError, { exact: false })).toBeNull()) + }) + + it('should allow to input an address from addressbook suggestions', async () => { + const invalidAddress = checksumAddress(faker.finance.ethereumAddress()) + const validAddress = checksumAddress(faker.finance.ethereumAddress()) + + const validationError = 'You cannot use this address' + const validation = (value: string) => (value === invalidAddress ? validationError : undefined) + + const { input, utils } = setup( + '', + { + [invalidAddress]: 'InvalidAddress', + [validAddress]: 'ValidAddress', + }, + validation, + ) + + expect(input).toHaveAttribute('aria-expanded', 'false') + + act(() => { + fireEvent.mouseDown(input) + fireEvent.mouseUp(input) + }) + + expect(input).toHaveAttribute('aria-expanded', 'true') + + act(() => { + fireEvent.click(utils.getByText('InvalidAddress')) + fireEvent.blur(input) + jest.advanceTimersByTime(1000) + }) + + // Should close auto completion and hide validation error + await waitFor(() => { + expect(utils.getByLabelText(validationError, { exact: false })).toBeDefined() + }) + + // Clear the input by clicking on the readonly input + act(() => { + // first click clears input + fireEvent.click(utils.getByLabelText(validationError, { exact: false })) + }) + + await waitFor(() => expect(utils.getByLabelText(validationError, { exact: false })).toHaveValue('')) + const newInput = utils.getByLabelText(validationError, { exact: false }) + expect(newInput).toBeVisible() + + act(() => { + // mousedown opens autocompletion again + fireEvent.mouseDown(newInput) + fireEvent.mouseUp(newInput) + }) + + act(() => { + fireEvent.click(utils.getByText('ValidAddress')) + fireEvent.blur(newInput) + + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => expect(utils.queryByLabelText(validationError, { exact: false })).toBeNull()) + + // should display name of address as well as address + await waitFor(() => expect(utils.getByText('ValidAddress', { exact: false })).toBeDefined()) + await waitFor(() => expect(utils.getByText(validAddress, { exact: false })).toBeDefined()) + }) + + it('should offer to add unknown addresses if canAdd is true', async () => { + const { input, utils } = setup('', {}, undefined, true) + + const newAddress = checksumAddress(faker.finance.ethereumAddress()) + act(() => { + fireEvent.change(input, { target: { value: newAddress } }) + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => expect(utils.getByText('add it to your address book', { exact: false })).toBeDefined()) + + await act(async () => { + fireEvent.click(utils.getByText('add it to your address book', { exact: false })) + // Wait for dialog to pop up to have it wrapped in the act + await Promise.resolve() + }) + + const nameInput = utils.getByLabelText('Name', { exact: false }) + act(() => { + fireEvent.change(nameInput, { target: { value: 'Tim Testermann' } }) + fireEvent.submit(nameInput) + }) + + await waitFor(() => expect(utils.getByText('Tim Testermann', { exact: false })).toBeDefined()) + }) + + it('should not offer to add unknown addresses if canAdd is false', async () => { + const { input, utils } = setup('', {}, undefined, false) + + const newAddress = checksumAddress(faker.finance.ethereumAddress()) + act(() => { + fireEvent.change(input, { target: { value: newAddress } }) + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => expect(utils.queryByText('add it to your address book', { exact: false })).toBeNull()) + }) +}) diff --git a/apps/web/src/components/common/AddressBookInput/index.tsx b/apps/web/src/components/common/AddressBookInput/index.tsx new file mode 100644 index 000000000..0dad6283d --- /dev/null +++ b/apps/web/src/components/common/AddressBookInput/index.tsx @@ -0,0 +1,143 @@ +import { type ReactElement, useState, useMemo } from 'react' +import { Controller, useFormContext, useWatch } from 'react-hook-form' +import { SvgIcon, Typography } from '@mui/material' +import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete' +import useAddressBook from '@/hooks/useAddressBook' +import AddressInput, { type AddressInputProps } from '../AddressInput' +import EthHashInfo from '../EthHashInfo' +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 { toChecksumAddress } from '@/utils/rsk-utils' +import useChainId from '@/hooks/useChainId' + +const abFilterOptions = createFilterOptions({ + stringify: (option: { label: string; name: string }) => option.name + ' ' + option.label, +}) + +/** + * Temporary component until revamped safe components are done + */ +const AddressBookInput = ({ name, canAdd, ...props }: AddressInputProps & { canAdd?: boolean }): ReactElement => { + const addressBook = useAddressBook() + const { setValue, control } = useFormContext() + const addressValue = useWatch({ name, control }) + const [open, setOpen] = useState(false) + const [openAddressBook, setOpenAddressBook] = useState(false) + const chainId = useChainId() + + const addressBookEntries = Object.entries(addressBook).map(([address, name]) => ({ + label: address, + name, + })) + + const hasVisibleOptions = useMemo( + () => !!addressBookEntries.filter((entry) => entry.label.includes(addressValue)).length, + [addressBookEntries, addressValue], + ) + + const isInAddressBook = useMemo( + () => addressBookEntries.some((entry) => sameAddress(entry.label, addressValue)), + [addressBookEntries, addressValue], + ) + + const customFilterOptions = (options: any, state: any) => { + // Don't show suggestions from the address book once a valid address has been entered. + if (isValidAddress(addressValue)) return [] + return abFilterOptions(options, state) + } + + const handleOpenAutocomplete = () => { + setOpen((value) => !value) + } + + const onAddressBookClick = canAdd + ? () => { + setOpenAddressBook(true) + } + : undefined + + const handleAddressChange = (value: string) => { + if (isValidAddress(value)) { + setValue(name, toChecksumAddress(value, chainId), { shouldValidate: true }) + } else { + setValue(name, value, { shouldValidate: true }) + } + } + + return ( + <> + ( + + typeof value === 'string' ? handleAddressChange(value) : handleAddressChange(value.label) + } + onInputChange={(_, value) => handleAddressChange(value)} + filterOptions={customFilterOptions} + componentsProps={{ + paper: { + elevation: 2, + }, + }} + renderOption={(props, option) => { + const { key, ...rest } = props + return ( + + + + ) + }} + renderInput={(params) => ( + + )} + /> + )} + /> + + {canAdd && !isInAddressBook ? ( + + + + This is an unknown address. You can{' '} + + add it to your address book + + . + + + ) : null} + + {openAddressBook && ( + setOpenAddressBook(false)} + defaultValues={{ name: '', address: addressValue }} + /> + )} + + ) +} + +export default AddressBookInput diff --git a/src/components/common/AddressBookInput/styles.module.css b/apps/web/src/components/common/AddressBookInput/styles.module.css similarity index 100% rename from src/components/common/AddressBookInput/styles.module.css rename to apps/web/src/components/common/AddressBookInput/styles.module.css diff --git a/apps/web/src/components/common/AddressInput/index.test.tsx b/apps/web/src/components/common/AddressInput/index.test.tsx new file mode 100644 index 000000000..ee5305e67 --- /dev/null +++ b/apps/web/src/components/common/AddressInput/index.test.tsx @@ -0,0 +1,364 @@ +import * as addressBook from '@/hooks/useAddressBook' +import * as allAddressBooks from '@/hooks/useAllAddressBooks' +import * as urlChainId from '@/hooks/useChainId' +import { act, fireEvent, waitFor } from '@testing-library/react' +import { render } from '@/tests/test-utils' +import { useForm, FormProvider } from 'react-hook-form' +import AddressInput, { type AddressInputProps } from '.' +import { useCurrentChain } from '@/hooks/useChains' +import useNameResolver from '@/components/common/AddressInput/useNameResolver' +import { chainBuilder } from '@/tests/builders/chains' +import { FEATURES } from '@safe-global/safe-gateway-typescript-sdk' +import userEvent from '@testing-library/user-event' + +const mockChain = chainBuilder() + .with({ features: [FEATURES.DOMAIN_LOOKUP] }) + .with({ chainId: '11155111' }) + .build() + +// mock useCurrentChain +jest.mock('@/hooks/useChains', () => ({ + useCurrentChain: jest.fn(() => mockChain), + useChain: jest.fn(() => mockChain), +})) + +// mock useNameResolver +jest.mock('@/components/common/AddressInput/useNameResolver', () => ({ + __esModule: true, + default: jest.fn((val: string) => ({ + address: val === 'zero.eth' ? '0x0000000000000000000000000000000000000000' : undefined, + resolverError: val === 'bogus.eth' ? new Error('Failed to resolve') : undefined, + resolving: false, + })), +})) + +const TestForm = ({ + address, + validate, + disabled, +}: { + address: string + validate?: AddressInputProps['validate'] + disabled?: boolean +}) => { + const name = 'recipient' + + const methods = useForm<{ + [name]: string + }>({ + defaultValues: { + [name]: address, + }, + mode: 'all', + }) + + return ( + +
null)}> + + + +
+ ) +} + +const setup = (address: string, validate?: AddressInputProps['validate'], disabled?: boolean) => { + const utils = render() + const input = utils.getByLabelText('Recipient address', { exact: false }) + + return { + input: input as HTMLInputElement, + utils, + } +} + +const TEST_ADDRESS_A = '0x0000000000000000000000000000000000000000' +const TEST_ADDRESS_B = '0x0000000000000000000000000000000000000001' + +describe('AddressInput tests', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + beforeEach(() => { + jest.clearAllMocks() + ;(useCurrentChain as jest.Mock).mockImplementation(() => mockChain) + jest.spyOn(addressBook, 'default').mockReturnValue({}) + }) + + it('should render with a default address value', () => { + const { input } = setup(TEST_ADDRESS_A) + expect(input.value).toBe(TEST_ADDRESS_A) + }) + + it('should render with a default prefixed address value', () => { + const { input } = setup(`eth:${TEST_ADDRESS_A}`) + expect(input.value).toBe(`eth:${TEST_ADDRESS_A}`) + }) + + it('should validate the address on input', async () => { + const { input, utils } = setup('') + + act(() => { + fireEvent.change(input, { target: { value: `eth:${TEST_ADDRESS_A}` } }) + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => + expect(utils.getByLabelText(`"eth" doesn't match the current chain`, { exact: false })).toBeDefined(), + ) + + // The validation error should persist on blur + await act(async () => { + fireEvent.blur(input) + jest.advanceTimersByTime(1000) + await Promise.resolve() + }) + + await waitFor(() => + expect(utils.getByLabelText(`"eth" doesn't match the current chain`, { exact: false })).toBeDefined(), + ) + + act(() => { + fireEvent.change(input, { target: { value: `${mockChain.shortName}:0x123` } }) + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => expect(utils.getByLabelText(`Invalid address format`, { exact: false })).toBeDefined()) + }) + + it('should accept a custom validate function', async () => { + const { input, utils } = setup('', (val) => `${val} is wrong`) + + act(() => { + fireEvent.change(input, { target: { value: `${mockChain.shortName}:${TEST_ADDRESS_A}` } }) + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => expect(utils.getByLabelText(`${TEST_ADDRESS_A} is wrong`, { exact: false })).toBeDefined()) + + act(() => { + fireEvent.change(input, { target: { value: `${mockChain.shortName}:${TEST_ADDRESS_B}` } }) + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => expect(utils.getByLabelText(`${TEST_ADDRESS_B} is wrong`, { exact: false })).toBeDefined()) + }) + + it('should show a spinner when validation is in progress', async () => { + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + + const { input, utils } = setup('', async (val) => { + await sleep(2000) + return `${val} is wrong` + }) + + act(() => { + fireEvent.change(input, { target: { value: `${mockChain.shortName}:${TEST_ADDRESS_A}` } }) + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(utils.getByRole('progressbar')).toBeDefined() + expect(utils.queryByLabelText(`${TEST_ADDRESS_A} is wrong`, { exact: false })).toBeNull() + }) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => expect(utils.getByLabelText(`${TEST_ADDRESS_A} is wrong`, { exact: false })).toBeDefined()) + }) + + it('should resolve ENS names', async () => { + const { input } = setup('') + + act(() => { + fireEvent.change(input, { target: { value: '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 () => { + const { input, utils } = setup('') + + act(() => { + fireEvent.change(input, { target: { value: 'bogus.eth' } }) + jest.advanceTimersByTime(1000) + }) + + expect(useNameResolver).toHaveBeenCalledWith('bogus.eth') + await waitFor(() => expect(utils.getByLabelText(`Failed to resolve`, { exact: false })).toBeDefined()) + }) + + it('should not resolve ENS names if this feature is disabled', async () => { + ;(useCurrentChain as jest.Mock).mockImplementation(() => ({ + shortName: 'gor', + chainId: '5', + chainName: 'Goerli', + features: [], + })) + + const { input, utils } = setup('') + + act(() => { + fireEvent.change(input, { target: { value: 'zero.eth' } }) + jest.advanceTimersByTime(1000) + }) + + expect(useNameResolver).toHaveBeenCalledWith('') + await waitFor(() => expect(input.value).toBe('zero.eth')) + await waitFor(() => expect(utils.getByLabelText('Invalid address format', { exact: false })).toBeDefined()) + }) + + it('should show chain prefix in an adornment', async () => { + const { input } = setup(TEST_ADDRESS_A) + + await waitFor(() => expect(input.value).toBe(TEST_ADDRESS_A)) + + expect(input.previousElementSibling?.textContent).toBe(`${mockChain.shortName}:`) + }) + + it('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() + }) + + await waitFor(() => expect(input.previousElementSibling?.textContent).toBe('')) + }) + + it('should keep a bare address in the form state', async () => { + let methods: any + + const Form = () => { + const name = 'recipient' + + methods = useForm<{ + [name]: string + }>({ + defaultValues: { + [name]: '', + }, + }) + + return ( + +
null)}> + + +
+ ) + } + + const utils = render(
) + const input = utils.getByLabelText('Recipient', { exact: false }) as HTMLInputElement + + act(() => { + fireEvent.change(input, { target: { value: `${mockChain.shortName}:${TEST_ADDRESS_A}` } }) + }) + + expect(methods.getValues().recipient).toBe(TEST_ADDRESS_A) + }) + + it('should clean up the input value if it contains a valid address', async () => { + ;(useCurrentChain as jest.Mock).mockImplementation(() => ({ + shortName: 'gor', + chainId: '5', + chainName: 'Goerli', + features: [], + })) + + const { input } = setup(``) + + act(() => { + fireEvent.change(input, { target: { value: `Here's my address: ${TEST_ADDRESS_A}` } }) + }) + + await waitFor(() => expect(input.value).toBe(TEST_ADDRESS_A)) + }) + + it('should display a read-only input if the address is in the address book', async () => { + const mockChainId = '11155111' + const mockSafeName = 'Test Safe' + const mockAB = { [TEST_ADDRESS_A]: mockSafeName } + + jest.spyOn(urlChainId, 'default').mockImplementation(() => mockChainId) + jest.spyOn(allAddressBooks, 'default').mockReturnValue({ [mockChainId]: mockAB }) + jest.spyOn(addressBook, 'default').mockImplementation(() => mockAB) + + const { input, utils } = setup(TEST_ADDRESS_A) + + act(() => { + fireEvent.change(input, { target: { value: TEST_ADDRESS_A } }) + }) + + await waitFor(() => expect(utils.getByText(mockSafeName)).toBeInTheDocument()) + }) + + it('should clear the input on click if the address is in the address book and not disabled', async () => { + const mockChainId = '11155111' + const mockSafeName = 'Test Safe' + const mockAB = { [TEST_ADDRESS_A]: mockSafeName } + + jest.spyOn(urlChainId, 'default').mockImplementation(() => mockChainId) + jest.spyOn(allAddressBooks, 'default').mockReturnValue({ [mockChainId]: mockAB }) + jest.spyOn(addressBook, 'default').mockImplementation(() => mockAB) + + const { input, utils } = setup(TEST_ADDRESS_A) + + act(() => { + fireEvent.change(input, { target: { value: TEST_ADDRESS_A } }) + }) + + await waitFor(() => { + expect(utils.getByText(mockSafeName)).toBeInTheDocument() + expect(utils.getByRole('textbox')).toHaveValue(TEST_ADDRESS_A) + }) + + act(() => { + userEvent.click(input) + }) + + await waitFor(() => expect(utils.getByRole('textbox')).toHaveValue('')) + }) + + it('should not clear the input on click if the address is in the address book and the input is disabled', async () => { + const mockChainId = '11155111' + const mockSafeName = 'Test Safe' + const mockAB = { [TEST_ADDRESS_A]: mockSafeName } + + jest.spyOn(urlChainId, 'default').mockImplementation(() => mockChainId) + jest.spyOn(allAddressBooks, 'default').mockReturnValue({ [mockChainId]: mockAB }) + jest.spyOn(addressBook, 'default').mockImplementation(() => mockAB) + + const { input, utils } = setup(TEST_ADDRESS_A, undefined, true) + + act(() => { + fireEvent.change(input, { target: { value: TEST_ADDRESS_A } }) + }) + + await waitFor(() => { + expect(utils.getByText(mockSafeName)).toBeInTheDocument() + expect(utils.getByRole('textbox')).toHaveValue(TEST_ADDRESS_A) + }) + + act(() => { + userEvent.click(input) + }) + + await waitFor(() => expect(utils.getByRole('textbox')).toHaveValue(TEST_ADDRESS_A)) + }) +}) diff --git a/apps/web/src/components/common/AddressInput/index.tsx b/apps/web/src/components/common/AddressInput/index.tsx new file mode 100644 index 000000000..857629b4b --- /dev/null +++ b/apps/web/src/components/common/AddressInput/index.tsx @@ -0,0 +1,200 @@ +import AddressInputReadOnly from '@/components/common/AddressInputReadOnly' +import useAddressBook from '@/hooks/useAddressBook' +import type { ReactElement } from 'react' +import { useEffect, useCallback, useRef, useMemo } from 'react' +import { + InputAdornment, + TextField, + type TextFieldProps, + CircularProgress, + IconButton, + SvgIcon, + Skeleton, +} from '@mui/material' +import { useFormContext, useWatch, type Validate, get } from 'react-hook-form' +import { validatePrefixedAddress } from '@/utils/validation' +import { useCurrentChain } from '@/hooks/useChains' +import useNameResolver from './useNameResolver' +import { FEATURES, hasFeature } from '@/utils/chains' +import { cleanInputValue, parsePrefixedAddress } from '@/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' +import classnames from 'classnames' +import css from './styles.module.css' +import inputCss from '@/styles/inputs.module.css' +import Identicon from '../Identicon' + +export type AddressInputProps = TextFieldProps & { + name: string + address?: string + onOpenListClick?: () => void + isAutocompleteOpen?: boolean + validate?: Validate + deps?: string | string[] + onAddressBookClick?: () => void +} + +const AddressInput = ({ + name, + validate, + required = true, + onOpenListClick, + isAutocompleteOpen, + onAddressBookClick, + deps, + ...props +}: AddressInputProps): ReactElement => { + const { + register, + setValue, + control, + formState: { errors, isValidating }, + trigger, + } = useFormContext() + + const currentChain = useCurrentChain() + const rawValueRef = useRef('') + const watchedValue = useWatch({ name, control }) + const currentShortName = currentChain?.shortName || '' + + const addressBook = useAddressBook() + + // Fetch an ENS resolution for the current address + const isDomainLookupEnabled = !!currentChain && hasFeature(currentChain, FEATURES.DOMAIN_LOOKUP) + const { address, resolverError, resolving } = useNameResolver(isDomainLookupEnabled ? watchedValue : '') + + // errors[name] doesn't work with nested field names like 'safe.address', need to use the lodash get + const fieldError = resolverError || get(errors, name) + + // Debounce the field error unless there's no error or it's resolving a domain + let error = useDebounce(fieldError, 500) + if (resolverError) error = resolverError + if (!fieldError || resolving) error = undefined + + // Validation function based on the current chain prefix + const validatePrefixed = useMemo(() => validatePrefixedAddress(currentShortName), [currentShortName]) + + // Update the input value + const setAddressValue = useCallback( + (value: string) => setValue(name, value, { shouldValidate: true }), + [setValue, name], + ) + + // On ENS resolution, update the input value + useEffect(() => { + if (address) { + setAddressValue(`${currentShortName}:${address}`) + } + }, [address, currentShortName, setAddressValue]) + + const endAdornment = ( + + {resolving || isValidating ? ( + + ) : !props.disabled ? ( + <> + {onAddressBookClick && ( + + + + )} + + {onOpenListClick && ( + + + + )} + + ) : null} + + ) + + const resetName = () => { + if (!props.disabled && addressBook[watchedValue]) { + setValue(name, '') + } + } + + return ( + <> + {error?.message || props.label || `Recipient address${isDomainLookupEnabled ? ' or ENS' : ''}`}} + error={!!error} + fullWidth + onClick={resetName} + spellCheck={false} + InputProps={{ + ...(props.InputProps || {}), + className: addressBook[watchedValue] ? css.readOnly : undefined, + + startAdornment: addressBook[watchedValue] ? ( + + ) : ( + // Display the current short name in the adornment, unless the value contains the same prefix + + {watchedValue && !fieldError ? ( + + ) : ( + + )} + + {!rawValueRef.current.startsWith(`${currentShortName}:`) && <>{currentShortName}:} + + ), + + endAdornment, + }} + InputLabelProps={{ + ...(props.InputLabelProps || {}), + shrink: true, + }} + {...register(name, { + deps, + + 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, currentChain?.chainId).address + } else { + // we keep invalid prefixes such that the validation error is persistet + return cleanValue + } + }, + + validate: async () => { + const value = rawValueRef.current + if (value) { + return ( + validatePrefixed(value, currentChain?.chainId) || + (await validate?.(parsePrefixedAddress(value, currentChain?.chainId).address)) + ) + } + }, + + // Workaround for a bug in react-hook-form that it restores a cached error state on blur + onBlur: () => setTimeout(() => trigger(name), 100), + })} + // Workaround for a bug in react-hook-form when `register().value` is cached after `setValueAs` + // Only seems to occur on the `/load` route + value={watchedValue} + /> + + ) +} + +export default AddressInput diff --git a/apps/web/src/components/common/AddressInput/styles.module.css b/apps/web/src/components/common/AddressInput/styles.module.css new file mode 100644 index 000000000..bd88bff8c --- /dev/null +++ b/apps/web/src/components/common/AddressInput/styles.module.css @@ -0,0 +1,19 @@ +.wrapper :global .MuiInputLabel-root.Mui-error[data-shrink='false'] { + padding: 5px 4px; +} + +.wrapper :global .MuiInputAdornment-root { + margin-left: 0; +} + +.openButton svg { + transition: transform 0.3s ease-in-out; +} + +.rotated svg { + transform: rotate(180deg); +} + +.readOnly :global .MuiInputBase-input { + visibility: hidden; +} diff --git a/src/components/common/AddressInput/useNameResolver.ts b/apps/web/src/components/common/AddressInput/useNameResolver.ts similarity index 100% rename from src/components/common/AddressInput/useNameResolver.ts rename to apps/web/src/components/common/AddressInput/useNameResolver.ts diff --git a/src/components/common/AddressInputReadOnly/index.tsx b/apps/web/src/components/common/AddressInputReadOnly/index.tsx similarity index 100% rename from src/components/common/AddressInputReadOnly/index.tsx rename to apps/web/src/components/common/AddressInputReadOnly/index.tsx diff --git a/src/components/common/AddressInputReadOnly/styles.module.css b/apps/web/src/components/common/AddressInputReadOnly/styles.module.css similarity index 100% rename from src/components/common/AddressInputReadOnly/styles.module.css rename to apps/web/src/components/common/AddressInputReadOnly/styles.module.css diff --git a/apps/web/src/components/common/BlockedAddress/index.tsx b/apps/web/src/components/common/BlockedAddress/index.tsx new file mode 100644 index 000000000..33e9c90d4 --- /dev/null +++ b/apps/web/src/components/common/BlockedAddress/index.tsx @@ -0,0 +1,36 @@ +import type { ReactElement } from 'react' +import { useMediaQuery, useTheme } from '@mui/material' +import { shortenAddress } from '@/utils/formatters' +import { useRouter } from 'next/router' +import Disclaimer from '@/components/common/Disclaimer' +import { AppRoutes } from '@/config/routes' + +export const BlockedAddress = ({ + address, + featureTitle, + onClose, +}: { + address: string + featureTitle: string + onClose?: () => void +}): ReactElement => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + const displayAddress = address && isMobile ? shortenAddress(address) : address + const router = useRouter() + + const handleAccept = () => { + router.push({ pathname: AppRoutes.home, query: router.query }) + } + + return ( + + ) +} + +export default BlockedAddress diff --git a/src/components/common/BlockedAddress/styles.module.css b/apps/web/src/components/common/BlockedAddress/styles.module.css similarity index 100% rename from src/components/common/BlockedAddress/styles.module.css rename to apps/web/src/components/common/BlockedAddress/styles.module.css diff --git a/apps/web/src/components/common/BuyCryptoButton/index.tsx b/apps/web/src/components/common/BuyCryptoButton/index.tsx new file mode 100644 index 000000000..2694e78a1 --- /dev/null +++ b/apps/web/src/components/common/BuyCryptoButton/index.tsx @@ -0,0 +1,106 @@ +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 AddIcon from '@mui/icons-material/Add' +import { SafeAppsTag } from '@/config/constants' +import { AppRoutes } from '@/config/routes' +import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' +import madProps from '@/utils/mad-props' +import { type ReactNode, useMemo } from 'react' +import Track from '../Track' +import { OVERVIEW_EVENTS } from '@/services/analytics' +import RampLogo from '@/public/images/common/ramp_logo.svg' +import css from './styles.module.css' +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded' + +const useOnrampAppUrl = (): string | undefined => { + const [onrampApps] = useRemoteSafeApps(SafeAppsTag.ONRAMP) + return onrampApps?.[0]?.url +} + +const useBuyCryptoHref = (): LinkProps['href'] | undefined => { + const query = useSearchParams() + const safe = query?.get('safe') + const appUrl = useOnrampAppUrl() + + return useMemo(() => { + if (!safe || !appUrl) return undefined + return { pathname: AppRoutes.apps.open, query: { safe, appUrl } } + }, [safe, appUrl]) +} + +const buttonStyles = { + minHeight: '37.5px', +} + +const BuyCryptoOption = ({ name, children }: { name: string; children: ReactNode }) => { + return ( + + + {children} + {name} + + + + ) +} + +const _BuyCryptoOptions = ({ rampLink }: { rampLink?: LinkProps['href'] }) => { + if (rampLink) { + return ( + + + + + + + + + + ) + } + + return ( + + Find an on-ramp provider that supports your region and on-ramp funds to your Safe Account address. + + ) +} + +const InternalBuyCryptoButton = ({ href, pagePath }: { href?: LinkProps['href']; pagePath: string }) => { + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')) + + if (!href) return null + + return ( + <> + + + + + + + ) +} + +const BuyCryptoButton = madProps(InternalBuyCryptoButton, { + href: useBuyCryptoHref, + pagePath: usePathname, +}) + +export const BuyCryptoOptions = madProps(_BuyCryptoOptions, { + rampLink: useBuyCryptoHref, +}) + +export default BuyCryptoButton diff --git a/src/components/common/BuyCryptoButton/styles.module.css b/apps/web/src/components/common/BuyCryptoButton/styles.module.css similarity index 100% rename from src/components/common/BuyCryptoButton/styles.module.css rename to apps/web/src/components/common/BuyCryptoButton/styles.module.css diff --git a/apps/web/src/components/common/ChainIndicator/index.tsx b/apps/web/src/components/common/ChainIndicator/index.tsx new file mode 100644 index 000000000..57c9fa0a9 --- /dev/null +++ b/apps/web/src/components/common/ChainIndicator/index.tsx @@ -0,0 +1,97 @@ +import type { ReactElement } from 'react' +import { useMemo } from 'react' +import classnames from 'classnames' +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 isEmpty from 'lodash/isEmpty' +import FiatValue from '../FiatValue' + +type ChainIndicatorProps = { + chainId?: string + inline?: boolean + className?: string + showUnknown?: boolean + showLogo?: boolean + onlyLogo?: boolean + responsive?: boolean + fiatValue?: string +} + +const fallbackChainConfig = { + chainName: 'Unknown chain', + chainId: '-1', + theme: { + backgroundColor: '#ddd', + textColor: '#000', + }, + chainLogoUri: null, +} + +const ChainIndicator = ({ + chainId, + fiatValue, + className, + inline = false, + showUnknown = true, + showLogo = true, + responsive = false, + onlyLogo = false, +}: ChainIndicatorProps): ReactElement | null => { + const currentChainId = useChainId() + const id = chainId || currentChainId + const chains = useAppSelector(selectChains) + const chainConfig = + useAppSelector((state) => selectChainById(state, id)) || (showUnknown ? fallbackChainConfig : null) + const noChains = isEmpty(chains.data) + + const style = useMemo(() => { + if (!chainConfig) return + const { theme } = chainConfig + + return { + backgroundColor: theme.backgroundColor, + color: theme.textColor, + } + }, [chainConfig]) + + return noChains ? ( + + ) : chainConfig ? ( + + {showLogo && ( + {`${chainConfig.chainName} + )} + {!onlyLogo && ( + + {chainConfig.chainName} + {fiatValue && ( + + + + )} + + )} + + ) : null +} + +export default ChainIndicator diff --git a/apps/web/src/components/common/ChainIndicator/styles.module.css b/apps/web/src/components/common/ChainIndicator/styles.module.css new file mode 100644 index 000000000..bf05c7ff1 --- /dev/null +++ b/apps/web/src/components/common/ChainIndicator/styles.module.css @@ -0,0 +1,52 @@ +.indicator { + display: flex; + align-items: center; + min-width: 70px; + font-size: 12px; + justify-content: center; +} + +.inlineIndicator { + display: inline-block; + min-width: 70px; + font-size: 11px; + line-height: normal; + text-align: center; + border-radius: 4px; + padding: 4px 8px; +} + +.withLogo { + display: flex; + align-items: center; + gap: var(--space-1); + padding: 0; + min-width: 115px; + font-size: 14px; + justify-content: flex-start; +} + +.onlyLogo { + min-width: 0; +} + +@media (max-width: 899.95px) { + .indicator { + min-width: 35px; + } + .responsive { + min-width: 0; + } + .responsive .name { + display: none; + } +} + +@container my-accounts-container (max-width: 500px) { + .responsive { + min-width: 0; + } + .responsive .name { + display: none; + } +} diff --git a/apps/web/src/components/common/ChainSwitcher/index.tsx b/apps/web/src/components/common/ChainSwitcher/index.tsx new file mode 100644 index 000000000..f0955d13c --- /dev/null +++ b/apps/web/src/components/common/ChainSwitcher/index.tsx @@ -0,0 +1,59 @@ +import type { ReactElement } from 'react' +import { useCallback, useState } from 'react' +import { Button, CircularProgress, Typography } from '@mui/material' +import { useCurrentChain } from '@/hooks/useChains' +import useOnboard from '@/hooks/wallets/useOnboard' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import { switchWalletChain } from '@/services/tx/tx-sender/sdk' + +const ChainSwitcher = ({ + fullWidth, + primaryCta = false, +}: { + fullWidth?: boolean + primaryCta?: boolean +}): ReactElement | null => { + const chain = useCurrentChain() + const onboard = useOnboard() + const isWrongChain = useIsWrongChain() + const [loading, setIsLoading] = useState(false) + + const handleChainSwitch = useCallback(async () => { + if (!onboard || !chain) return + setIsLoading(true) + await switchWalletChain(onboard, chain.chainId) + setIsLoading(false) + }, [chain, onboard]) + + if (!isWrongChain) return null + + return ( + + ) +} + +export default ChainSwitcher diff --git a/src/components/common/ChainSwitcher/styles.module.css b/apps/web/src/components/common/ChainSwitcher/styles.module.css similarity index 100% rename from src/components/common/ChainSwitcher/styles.module.css rename to apps/web/src/components/common/ChainSwitcher/styles.module.css diff --git a/apps/web/src/components/common/CheckWallet/index.test.tsx b/apps/web/src/components/common/CheckWallet/index.test.tsx new file mode 100644 index 000000000..bfa0a64bd --- /dev/null +++ b/apps/web/src/components/common/CheckWallet/index.test.tsx @@ -0,0 +1,284 @@ +import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import { render } from '@/tests/test-utils' +import CheckWallet from '.' +import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import useWallet from '@/hooks/wallets/useWallet' +import { chainBuilder } from '@/tests/builders/chains' +import { useIsWalletProposer } from '@/hooks/useProposers' +import { faker } from '@faker-js/faker' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners' +import type Safe from '@safe-global/protocol-kit' + +const mockWalletAddress = faker.finance.ethereumAddress() +// mock useWallet +jest.mock('@/hooks/wallets/useWallet', () => ({ + __esModule: true, + default: jest.fn(() => ({ + address: mockWalletAddress, + })), +})) + +// mock useIsSafeOwner +jest.mock('@/hooks/useIsSafeOwner', () => ({ + __esModule: true, + default: jest.fn(() => true), +})) + +// mock useIsOnlySpendingLimitBeneficiary +jest.mock('@/hooks/useIsOnlySpendingLimitBeneficiary', () => ({ + __esModule: true, + default: jest.fn(() => false), +})) + +// mock useCurrentChain +jest.mock('@/hooks/useChains', () => ({ + __esModule: true, + useCurrentChain: jest.fn(() => chainBuilder().build()), +})) + +// mock useIsWrongChain +jest.mock('@/hooks/useIsWrongChain', () => ({ + __esModule: true, + default: jest.fn(() => false), +})) + +jest.mock('@/hooks/useProposers', () => ({ + __esModule: true, + useIsWalletProposer: jest.fn(() => false), +})) + +jest.mock('@/hooks/useSafeInfo', () => ({ + __esModule: true, + default: jest.fn(() => { + const safeAddress = faker.finance.ethereumAddress() + return { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: true }) + .build(), + } + }), +})) + +jest.mock('@/hooks/useNestedSafeOwners') +const mockUseNestedSafeOwners = useNestedSafeOwners as jest.MockedFunction + +jest.mock('@/hooks/coreSDK/safeCoreSDK') +const mockUseSafeSdk = useSafeSDK as jest.MockedFunction + +const renderButton = () => + render({(isOk) => }) + +describe('CheckWallet', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseSafeSdk.mockReturnValue({} as unknown as Safe) + mockUseNestedSafeOwners.mockReturnValue([]) + }) + + it('renders correctly when the wallet is connected to the right chain and is an owner', () => { + const { getByText } = renderButton() + + // Check that the button is enabled + expect(getByText('Continue')).not.toBeDisabled() + }) + + it('should disable the button when the wallet is not connected', () => { + ;(useWallet as jest.MockedFunction).mockReturnValueOnce(null) + + const { getByText, getByLabelText } = renderButton() + + // Check that the button is disabled + expect(getByText('Continue')).toBeDisabled() + + // Check the tooltip text + expect(getByLabelText('Please connect your wallet')).toBeInTheDocument() + }) + + it('should disable the button when the wallet is connected to the right chain but is not an owner', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + + const { getByText, getByLabelText } = renderButton() + + expect(getByText('Continue')).toBeDisabled() + expect(getByLabelText('Your connected wallet is not a signer of this Safe Account')).toBeInTheDocument() + }) + + it('should be disabled when connected to the wrong network', () => { + ;(useIsWrongChain as jest.MockedFunction).mockReturnValue(true) + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + + const renderButtonWithNetworkCheck = () => + render({(isOk) => }) + + const { getByText } = renderButtonWithNetworkCheck() + + expect(getByText('Continue')).toBeDisabled() + }) + + it('should not disable the button for non-owner spending limit benificiaries', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;( + useIsOnlySpendingLimitBeneficiary as jest.MockedFunction + ).mockReturnValueOnce(true) + + const { getByText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).not.toBeDisabled() + }) + + it('should not disable the button for proposers', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;(useIsWalletProposer as jest.MockedFunction).mockReturnValueOnce(true) + + const { getByText } = renderButton() + + expect(getByText('Continue')).not.toBeDisabled() + }) + + it('should disable the button for proposers if specified via flag', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;(useIsWalletProposer as jest.MockedFunction).mockReturnValueOnce(true) + + const { getByText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).toBeDisabled() + }) + + it('should not disable the button for proposers that are also owners', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + ;(useIsWalletProposer as jest.MockedFunction).mockReturnValueOnce(true) + + const { getByText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).not.toBeDisabled() + }) + + it('should disable the button for counterfactual Safes', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: false }) + .build(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { getByText, getByLabelText } = renderButton() + + expect(getByText('Continue')).toBeDisabled() + expect(getByLabelText('You need to activate the Safe before transacting')).toBeInTheDocument() + }) + + it('should enable the button for counterfactual Safes if allowed', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: false }) + .build(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { getByText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).toBeEnabled() + }) + + it('should allow non-owners if specified', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + + const { getByText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).not.toBeDisabled() + }) + + it('should not allow non-owners that have a spending limit without allowing spending limits', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;( + useIsOnlySpendingLimitBeneficiary as jest.MockedFunction + ).mockReturnValueOnce(true) + + const { getByText } = render({(isOk) => }) + + expect(getByText('Continue')).toBeDisabled() + }) + + it('should disable the button if SDK is not initialized and safe is loaded', () => { + mockUseSafeSdk.mockReturnValue(undefined) + + const mockSafeInfo = { + safeLoaded: true, + safe: extendedSafeInfoBuilder(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { getByText, getByLabelText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).toBeDisabled() + expect(getByLabelText('SDK is not initialized yet')) + }) + + it('should not disable the button if SDK is not initialized and safe is not loaded', () => { + mockUseSafeSdk.mockReturnValue(undefined) + + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: true }) + .build(), + safeLoaded: false, + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { queryByText } = render({(isOk) => }) + + expect(queryByText('Continue')).not.toBeDisabled() + }) + + it('should allow nested Safe owners', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + mockUseNestedSafeOwners.mockReturnValue([faker.finance.ethereumAddress()]) + + const { container } = render({(isOk) => }) + console.log(container.innerHTML) + expect(container.querySelector('button')).not.toBeDisabled() + }) +}) diff --git a/apps/web/src/components/common/CheckWallet/index.tsx b/apps/web/src/components/common/CheckWallet/index.tsx new file mode 100644 index 000000000..16794ea8a --- /dev/null +++ b/apps/web/src/components/common/CheckWallet/index.tsx @@ -0,0 +1,104 @@ +import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import { useIsWalletProposer } from '@/hooks/useProposers' +import { useMemo, type ReactElement } from 'react' +import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import useWallet from '@/hooks/wallets/useWallet' +import useConnectWallet from '../ConnectWallet/useConnectWallet' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import { Tooltip } from '@mui/material' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner' + +type CheckWalletProps = { + children: (ok: boolean) => ReactElement + allowSpendingLimit?: boolean + allowNonOwner?: boolean + noTooltip?: boolean + checkNetwork?: boolean + allowUndeployedSafe?: boolean + allowProposer?: boolean +} + +enum Message { + WalletNotConnected = 'Please connect your wallet', + SDKNotInitialized = 'SDK is not initialized yet', + NotSafeOwner = 'Your connected wallet is not a signer of this Safe Account', + SafeNotActivated = 'You need to activate the Safe before transacting', +} + +const CheckWallet = ({ + children, + allowSpendingLimit, + allowNonOwner, + noTooltip, + checkNetwork = false, + allowUndeployedSafe = false, + allowProposer = true, +}: CheckWalletProps): ReactElement => { + const wallet = useWallet() + const isSafeOwner = useIsSafeOwner() + const isOnlySpendingLimit = useIsOnlySpendingLimitBeneficiary() + const connectWallet = useConnectWallet() + const isWrongChain = useIsWrongChain() + const sdk = useSafeSDK() + const isProposer = useIsWalletProposer() + + const { safe, safeLoaded } = useSafeInfo() + + const isNestedSafeOwner = useIsNestedSafeOwner() + + const isUndeployedSafe = !safe.deployed + + const message = useMemo(() => { + if (!wallet) { + return Message.WalletNotConnected + } + if (!sdk && safeLoaded) { + return Message.SDKNotInitialized + } + + if (isUndeployedSafe && !allowUndeployedSafe) { + return Message.SafeNotActivated + } + + if ( + !allowNonOwner && + !isSafeOwner && + !isProposer && + !isNestedSafeOwner && + (!isOnlySpendingLimit || !allowSpendingLimit) + ) { + return Message.NotSafeOwner + } + + if (!allowProposer && isProposer && !isSafeOwner) { + return Message.NotSafeOwner + } + }, [ + allowNonOwner, + allowProposer, + allowSpendingLimit, + allowUndeployedSafe, + isProposer, + isNestedSafeOwner, + isOnlySpendingLimit, + isSafeOwner, + isUndeployedSafe, + sdk, + wallet, + safeLoaded, + ]) + + if (checkNetwork && isWrongChain) return children(false) + if (!message) return children(true) + if (noTooltip) return children(false) + + return ( + + {children(false)} + + ) +} + +export default CheckWallet diff --git a/apps/web/src/components/common/CheckWalletWithPermission/index.test.tsx b/apps/web/src/components/common/CheckWalletWithPermission/index.test.tsx new file mode 100644 index 000000000..9add1a7a5 --- /dev/null +++ b/apps/web/src/components/common/CheckWalletWithPermission/index.test.tsx @@ -0,0 +1,204 @@ +import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import { render } from '@/tests/test-utils' +import CheckWalletWithPermission from './index' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import useWallet from '@/hooks/wallets/useWallet' +import { chainBuilder } from '@/tests/builders/chains' +import { faker } from '@faker-js/faker' +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' + +const mockWalletAddress = faker.finance.ethereumAddress() +// mock useWallet +jest.mock('@/hooks/wallets/useWallet', () => ({ + __esModule: true, + default: jest.fn(() => ({ + address: mockWalletAddress, + })), +})) + +// mock useCurrentChain +jest.mock('@/hooks/useChains', () => ({ + __esModule: true, + useCurrentChain: jest.fn(() => chainBuilder().build()), +})) + +// mock useIsWrongChain +jest.mock('@/hooks/useIsWrongChain', () => ({ + __esModule: true, + default: jest.fn(() => false), +})) + +jest.mock('@/hooks/useSafeInfo', () => ({ + __esModule: true, + default: jest.fn(() => { + const safeAddress = faker.finance.ethereumAddress() + return { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: true }) + .build(), + } + }), +})) + +jest.mock('@/hooks/coreSDK/safeCoreSDK') +const mockUseSafeSdk = useSafeSDK as jest.MockedFunction + +const renderButton = () => + render( + + {(isOk) => } + , + ) + +describe('CheckWalletWithPermission', () => { + const useHasPermissionSpy = jest.spyOn(useHasPermission, 'useHasPermission') + + beforeEach(() => { + jest.clearAllMocks() + mockUseSafeSdk.mockReturnValue({} as unknown as Safe) + useHasPermissionSpy.mockReturnValue(true) + }) + + it('renders correctly when the wallet is connected to the right chain and is an owner', () => { + const { getByText } = renderButton() + + // Check that the button is enabled + expect(getByText('Continue')).not.toBeDisabled() + }) + + it('should disable the button when the wallet is not connected', () => { + ;(useWallet as jest.MockedFunction).mockReturnValueOnce(null) + + const { getByText, getByLabelText } = renderButton() + + // Check that the button is disabled + expect(getByText('Continue')).toBeDisabled() + + // Check the tooltip text + expect(getByLabelText('Please connect your wallet')).toBeInTheDocument() + }) + + it('should disable the button when the current user does not have the specified permission', () => { + useHasPermissionSpy.mockReturnValue(false) + + const { getByText, getByLabelText } = renderButton() + + expect(getByText('Continue')).toBeDisabled() + expect(getByLabelText('Your connected wallet is not a signer of this Safe Account')).toBeInTheDocument() + + expect(useHasPermissionSpy).toHaveBeenCalledTimes(1) + expect(useHasPermissionSpy).toHaveBeenCalledWith(Permission.SignTransaction) + }) + + it('should be disabled when connected to the wrong network', () => { + ;(useIsWrongChain as jest.MockedFunction).mockReturnValue(true) + + const renderButtonWithNetworkCheck = () => + render( + + {(isOk) => } + , + ) + + const { getByText } = renderButtonWithNetworkCheck() + + expect(getByText('Continue')).toBeDisabled() + }) + + it('should disable the button for counterfactual Safes', () => { + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: false }) + .build(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { getByText, getByLabelText } = renderButton() + + expect(getByText('Continue')).toBeDisabled() + expect(getByLabelText('You need to activate the Safe before transacting')).toBeInTheDocument() + }) + + it('should enable the button for counterfactual Safes if allowed', () => { + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: false }) + .build(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { getByText } = render( + + {(isOk) => } + , + ) + + expect(getByText('Continue')).toBeEnabled() + }) + + it('should disable the button if SDK is not initialized and safe is loaded', () => { + mockUseSafeSdk.mockReturnValue(undefined) + + const mockSafeInfo = { + safeLoaded: true, + safe: extendedSafeInfoBuilder(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { getByText, getByLabelText } = render( + + {(isOk) => } + , + ) + + expect(getByText('Continue')).toBeDisabled() + expect(getByLabelText('SDK is not initialized yet')) + }) + + it('should not disable the button if SDK is not initialized and safe is not loaded', () => { + mockUseSafeSdk.mockReturnValue(undefined) + + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: true }) + .build(), + safeLoaded: false, + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { queryByText } = render( + + {(isOk) => } + , + ) + + expect(queryByText('Continue')).not.toBeDisabled() + }) +}) diff --git a/apps/web/src/components/common/CheckWalletWithPermission/index.tsx b/apps/web/src/components/common/CheckWalletWithPermission/index.tsx new file mode 100644 index 000000000..9ac63ad89 --- /dev/null +++ b/apps/web/src/components/common/CheckWalletWithPermission/index.tsx @@ -0,0 +1,81 @@ +import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import { useMemo, type ReactElement } from 'react' +import useWallet from '@/hooks/wallets/useWallet' +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 { useHasPermission } from '@/permissions/hooks/useHasPermission' + +type CheckWalletWithPermissionProps< + P extends Permission, + PProps = PermissionProps

extends undefined ? { permissionProps?: never } : { permissionProps: PermissionProps

}, +> = { + children: (ok: boolean) => ReactElement + permission: P + noTooltip?: boolean + checkNetwork?: boolean + allowUndeployedSafe?: boolean +} & PProps + +enum Message { + WalletNotConnected = 'Please connect your wallet', + SDKNotInitialized = 'SDK is not initialized yet', + NotSafeOwner = 'Your connected wallet is not a signer of this Safe Account', + SafeNotActivated = 'You need to activate the Safe before transacting', +} + +const CheckWalletWithPermission =

({ + children, + permission, + permissionProps, + noTooltip, + checkNetwork = false, + allowUndeployedSafe = false, +}: CheckWalletWithPermissionProps

): ReactElement => { + const wallet = useWallet() + const connectWallet = useConnectWallet() + const isWrongChain = useIsWrongChain() + const sdk = useSafeSDK() + const hasPermission = useHasPermission( + permission, + ...((permissionProps ? [permissionProps] : []) as PermissionProps

extends undefined + ? [] + : [props: PermissionProps

]), + ) + + const { safe, safeLoaded } = useSafeInfo() + + const isUndeployedSafe = !safe.deployed + + const message = useMemo(() => { + if (!wallet) { + return Message.WalletNotConnected + } + + if (!sdk && safeLoaded) { + return Message.SDKNotInitialized + } + + if (isUndeployedSafe && !allowUndeployedSafe) { + return Message.SafeNotActivated + } + + if (!hasPermission) { + return Message.NotSafeOwner + } + }, [allowUndeployedSafe, hasPermission, isUndeployedSafe, sdk, wallet, safeLoaded]) + + if (checkNetwork && isWrongChain) return children(false) + if (!message) return children(true) + if (noTooltip) return children(false) + + return ( + + {children(false)} + + ) +} + +export default CheckWalletWithPermission diff --git a/apps/web/src/components/common/Chip/index.tsx b/apps/web/src/components/common/Chip/index.tsx new file mode 100644 index 000000000..f87237f24 --- /dev/null +++ b/apps/web/src/components/common/Chip/index.tsx @@ -0,0 +1,32 @@ +import { Typography, Chip as MuiChip, type ChipProps } from '@mui/material' + +type Props = { + label?: string + sx?: ChipProps['sx'] +} + +export function Chip({ sx, label = 'New' }: Props) { + return ( + + {label} + + } + /> + ) +} diff --git a/apps/web/src/components/common/ChoiceButton/index.tsx b/apps/web/src/components/common/ChoiceButton/index.tsx new file mode 100644 index 000000000..c9b72e428 --- /dev/null +++ b/apps/web/src/components/common/ChoiceButton/index.tsx @@ -0,0 +1,61 @@ +import { type ElementType } from 'react' +import { Box, ButtonBase, SvgIcon, type SvgIconOwnProps, Typography } from '@mui/material' +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded' +import css from './styles.module.css' + +const ChoiceButton = ({ + title, + description, + icon, + iconColor, + onClick, + disabled, + chip, +}: { + title: string + description?: string + icon: ElementType + iconColor?: SvgIconOwnProps['color'] + onClick: () => void + disabled?: boolean + chip?: string +}) => { + return ( + + + + + + + {title} + + + {description && ( + + {description} + + )} + + + {chip && {chip}} + + ) +} + +export default ChoiceButton diff --git a/src/components/common/ChoiceButton/styles.module.css b/apps/web/src/components/common/ChoiceButton/styles.module.css similarity index 100% rename from src/components/common/ChoiceButton/styles.module.css rename to apps/web/src/components/common/ChoiceButton/styles.module.css diff --git a/src/components/common/ConnectWallet/AccountCenter.tsx b/apps/web/src/components/common/ConnectWallet/AccountCenter.tsx similarity index 98% rename from src/components/common/ConnectWallet/AccountCenter.tsx rename to apps/web/src/components/common/ConnectWallet/AccountCenter.tsx index e38c84434..b625c5c5c 100644 --- a/src/components/common/ConnectWallet/AccountCenter.tsx +++ b/apps/web/src/components/common/ConnectWallet/AccountCenter.tsx @@ -35,7 +35,7 @@ export const AccountCenter = ({ wallet }: { wallet: ConnectedWallet }) => { - + {open ? : } diff --git a/src/components/common/ConnectWallet/ConnectWalletButton.tsx b/apps/web/src/components/common/ConnectWallet/ConnectWalletButton.tsx similarity index 100% rename from src/components/common/ConnectWallet/ConnectWalletButton.tsx rename to apps/web/src/components/common/ConnectWallet/ConnectWalletButton.tsx diff --git a/src/components/common/ConnectWallet/ConnectionCenter.tsx b/apps/web/src/components/common/ConnectWallet/ConnectionCenter.tsx similarity index 100% rename from src/components/common/ConnectWallet/ConnectionCenter.tsx rename to apps/web/src/components/common/ConnectWallet/ConnectionCenter.tsx diff --git a/src/components/common/ConnectWallet/__tests__/AccountCenter.test.tsx b/apps/web/src/components/common/ConnectWallet/__tests__/AccountCenter.test.tsx similarity index 100% rename from src/components/common/ConnectWallet/__tests__/AccountCenter.test.tsx rename to apps/web/src/components/common/ConnectWallet/__tests__/AccountCenter.test.tsx diff --git a/src/components/common/ConnectWallet/__tests__/ConnectionCenter.test.tsx b/apps/web/src/components/common/ConnectWallet/__tests__/ConnectionCenter.test.tsx similarity index 100% rename from src/components/common/ConnectWallet/__tests__/ConnectionCenter.test.tsx rename to apps/web/src/components/common/ConnectWallet/__tests__/ConnectionCenter.test.tsx diff --git a/src/components/common/ConnectWallet/index.tsx b/apps/web/src/components/common/ConnectWallet/index.tsx similarity index 100% rename from src/components/common/ConnectWallet/index.tsx rename to apps/web/src/components/common/ConnectWallet/index.tsx diff --git a/src/components/common/ConnectWallet/styles.module.css b/apps/web/src/components/common/ConnectWallet/styles.module.css similarity index 100% rename from src/components/common/ConnectWallet/styles.module.css rename to apps/web/src/components/common/ConnectWallet/styles.module.css diff --git a/src/components/common/ConnectWallet/useConnectWallet.ts b/apps/web/src/components/common/ConnectWallet/useConnectWallet.ts similarity index 100% rename from src/components/common/ConnectWallet/useConnectWallet.ts rename to apps/web/src/components/common/ConnectWallet/useConnectWallet.ts diff --git a/src/components/common/ContextMenu/index.tsx b/apps/web/src/components/common/ContextMenu/index.tsx similarity index 100% rename from src/components/common/ContextMenu/index.tsx rename to apps/web/src/components/common/ContextMenu/index.tsx diff --git a/src/components/common/ContextMenu/styles.module.css b/apps/web/src/components/common/ContextMenu/styles.module.css similarity index 100% rename from src/components/common/ContextMenu/styles.module.css rename to apps/web/src/components/common/ContextMenu/styles.module.css diff --git a/apps/web/src/components/common/CookieAndTermBanner/index.tsx b/apps/web/src/components/common/CookieAndTermBanner/index.tsx new file mode 100644 index 000000000..0a00abb69 --- /dev/null +++ b/apps/web/src/components/common/CookieAndTermBanner/index.tsx @@ -0,0 +1,201 @@ +import { useEffect, type ReactElement } from 'react' +import classnames from 'classnames' +import type { CheckboxProps } from '@mui/material' +import { Grid, Button, Checkbox, FormControlLabel, Typography, Paper, SvgIcon, Box } from '@mui/material' +import WarningIcon from '@/public/images/notifications/warning.svg' +import { useForm } from 'react-hook-form' +import * as metadata from '@/markdown/terms/version' + +import { useAppDispatch, useAppSelector } from '@/store' +import { + selectCookies, + CookieAndTermType, + saveCookieAndTermConsent, + hasAcceptedTerms, +} from '@/store/cookiesAndTermsSlice' +import { selectCookieBanner, openCookieBanner, closeCookieBanner } from '@/store/popupSlice' + +import css from './styles.module.css' +import { AppRoutes } from '@/config/routes' +import Link from 'next/link' + +const COOKIE_AND_TERM_WARNING: Record = { + [CookieAndTermType.TERMS]: '', + [CookieAndTermType.NECESSARY]: '', + [CookieAndTermType.UPDATES]: ``, + [CookieAndTermType.ANALYTICS]: '', +} + +const CookieCheckbox = ({ + checkboxProps, + label, + checked, +}: { + label: string + checked: boolean + checkboxProps: CheckboxProps +}) => } sx={{ mt: '-9px' }} /> + +export const CookieAndTermBanner = ({ + warningKey, + inverted, +}: { + warningKey?: CookieAndTermType + inverted?: boolean +}): ReactElement => { + const warning = warningKey ? COOKIE_AND_TERM_WARNING[warningKey] : undefined + const dispatch = useAppDispatch() + const cookies = useAppSelector(selectCookies) + + const { getValues, setValue } = useForm({ + defaultValues: { + [CookieAndTermType.TERMS]: true, + [CookieAndTermType.NECESSARY]: true, + [CookieAndTermType.UPDATES]: cookies[CookieAndTermType.UPDATES] ?? false, + [CookieAndTermType.ANALYTICS]: cookies[CookieAndTermType.ANALYTICS] ?? false, + ...(warningKey ? { [warningKey]: true } : {}), + }, + }) + + const handleAccept = () => { + const values = getValues() + dispatch( + saveCookieAndTermConsent({ + ...values, + termsVersion: metadata.version, + }), + ) + dispatch(closeCookieBanner()) + } + + const handleAcceptAll = () => { + setValue(CookieAndTermType.UPDATES, true) + setValue(CookieAndTermType.ANALYTICS, true) + setTimeout(handleAccept, 300) + } + + return ( + + {warning && ( + + {warning} + + )} + + + + + By browsing this page, you accept our{' '} + + Terms & Conditions + {' '} + and the use of necessary cookies.{' '} + + Cookie policy + + . + + + + + + +
+ Locally stored data for core functionality +
+ {/* + + +
+ New features and product announcements +
+ + + +
+ + Opt in for Google Analytics cookies to help us analyze app usage patterns. + +
*/} +
+
+ + + + + + +
+
+ +
+ ) +} + +const CookieBannerPopup = (): ReactElement | null => { + const cookiePopup = useAppSelector(selectCookieBanner) + const dispatch = useAppDispatch() + + const hasAccepted = useAppSelector(hasAcceptedTerms) + const shouldOpen = !hasAccepted + + useEffect(() => { + if (shouldOpen) { + dispatch(openCookieBanner({})) + } else { + dispatch(closeCookieBanner()) + } + }, [dispatch, shouldOpen]) + + return cookiePopup.open ? ( +

+ +
+ ) : null +} +export default CookieBannerPopup diff --git a/apps/web/src/components/common/CookieAndTermBanner/styles.module.css b/apps/web/src/components/common/CookieAndTermBanner/styles.module.css new file mode 100644 index 000000000..aa540b514 --- /dev/null +++ b/apps/web/src/components/common/CookieAndTermBanner/styles.module.css @@ -0,0 +1,42 @@ +.popup { + position: fixed; + z-index: 1300; + bottom: var(--space-2); + right: var(--space-2); + max-width: 400px; +} + +.container { + padding: var(--space-2); + border-radius: 0 !important; +} + +.container label, +.container input { + user-select: none; +} + +@media (max-width: 599.95px) { + .popup { + right: 0; + bottom: 0; + } +} + +.container.inverted { + background: var(--color-text-primary); +} + +.container.inverted, +.container.inverted :global(.MuiCheckbox-root), +.container.inverted a { + color: var(--color-background-paper); +} + +.container.inverted :global(.Mui-checked) { + color: var(--color-background-paper); +} + +.container.inverted :global(.Mui-checked.Mui-disabled) { + opacity: 0.5; +} diff --git a/src/components/common/CooldownButton/index.test.tsx b/apps/web/src/components/common/CooldownButton/index.test.tsx similarity index 100% rename from src/components/common/CooldownButton/index.test.tsx rename to apps/web/src/components/common/CooldownButton/index.test.tsx diff --git a/src/components/common/CooldownButton/index.tsx b/apps/web/src/components/common/CooldownButton/index.tsx similarity index 100% rename from src/components/common/CooldownButton/index.tsx rename to apps/web/src/components/common/CooldownButton/index.tsx diff --git a/src/components/common/CopyAddressButton/__tests__/index.test.tsx b/apps/web/src/components/common/CopyAddressButton/__tests__/index.test.tsx similarity index 100% rename from src/components/common/CopyAddressButton/__tests__/index.test.tsx rename to apps/web/src/components/common/CopyAddressButton/__tests__/index.test.tsx diff --git a/apps/web/src/components/common/CopyAddressButton/index.tsx b/apps/web/src/components/common/CopyAddressButton/index.tsx new file mode 100644 index 000000000..5b3f8be9e --- /dev/null +++ b/apps/web/src/components/common/CopyAddressButton/index.tsx @@ -0,0 +1,38 @@ +import { Box, Typography } from '@mui/material' +import type { ReactNode, ReactElement } from 'react' +import CopyButton from '../CopyButton' +import EthHashInfo from '../EthHashInfo' + +const CopyAddressButton = ({ + prefix, + address, + copyPrefix, + children, + trusted = true, +}: { + prefix?: string + address: string + copyPrefix?: boolean + children?: ReactNode + trusted?: boolean +}): ReactElement => { + const addressText = copyPrefix && prefix ? `${prefix}:${address}` : address + + const dialogContent = trusted ? undefined : ( + + + + The copied address is linked to a transaction with an untrusted token. Make sure you are interacting with the + right address. + + + ) + + return ( + + {children} + + ) +} + +export default CopyAddressButton diff --git a/src/components/common/CopyButton/index.stories.tsx b/apps/web/src/components/common/CopyButton/index.stories.tsx similarity index 100% rename from src/components/common/CopyButton/index.stories.tsx rename to apps/web/src/components/common/CopyButton/index.stories.tsx diff --git a/src/components/common/CopyButton/index.tsx b/apps/web/src/components/common/CopyButton/index.tsx similarity index 100% rename from src/components/common/CopyButton/index.tsx rename to apps/web/src/components/common/CopyButton/index.tsx diff --git a/apps/web/src/components/common/CopyTooltip/ConfirmCopyModal.tsx b/apps/web/src/components/common/CopyTooltip/ConfirmCopyModal.tsx new file mode 100644 index 000000000..d659346f0 --- /dev/null +++ b/apps/web/src/components/common/CopyTooltip/ConfirmCopyModal.tsx @@ -0,0 +1,69 @@ +import { Close } from '@mui/icons-material' +import { + Dialog, + DialogTitle, + SvgIcon, + Typography, + IconButton, + Divider, + DialogContent, + DialogActions, + Button, + Box, +} from '@mui/material' +import WarningIcon from '@/public/images/notifications/warning.svg' +import { type ReactElement, useEffect, type SyntheticEvent } from 'react' +import { trackEvent, TX_LIST_EVENTS } from '@/services/analytics' +import Track from '../Track' + +import css from './styles.module.css' + +export type ConfirmCopyModalProps = { + open: boolean + onClose: () => void + onCopy: { (e: SyntheticEvent): void } + children: ReactElement +} + +const ConfirmCopyModal = ({ open, onClose, onCopy, children }: ConfirmCopyModalProps) => { + useEffect(() => { + if (open) { + trackEvent(TX_LIST_EVENTS.COPY_WARNING_SHOWN) + } + }, [open]) + + return ( + + + + + + Before you copy + + + + + + + + {children} + + + + + + + + + + + + + ) +} + +export default ConfirmCopyModal diff --git a/src/components/common/CopyTooltip/index.tsx b/apps/web/src/components/common/CopyTooltip/index.tsx similarity index 100% rename from src/components/common/CopyTooltip/index.tsx rename to apps/web/src/components/common/CopyTooltip/index.tsx diff --git a/apps/web/src/components/common/CopyTooltip/styles.module.css b/apps/web/src/components/common/CopyTooltip/styles.module.css new file mode 100644 index 000000000..6edc2a146 --- /dev/null +++ b/apps/web/src/components/common/CopyTooltip/styles.module.css @@ -0,0 +1,15 @@ +.dialogActions { + display: flex; + flex-direction: row; + align-items: center; +} + +@media (max-width: 599.95px) { + .dialogActions { + flex-direction: column; + width: 100%; + } + .dialogActions > span { + width: 100%; + } +} diff --git a/src/components/common/Countdown/index.test.tsx b/apps/web/src/components/common/Countdown/index.test.tsx similarity index 100% rename from src/components/common/Countdown/index.test.tsx rename to apps/web/src/components/common/Countdown/index.test.tsx diff --git a/src/components/common/Countdown/index.tsx b/apps/web/src/components/common/Countdown/index.tsx similarity index 100% rename from src/components/common/Countdown/index.tsx rename to apps/web/src/components/common/Countdown/index.tsx diff --git a/apps/web/src/components/common/CustomLink/index.tsx b/apps/web/src/components/common/CustomLink/index.tsx new file mode 100644 index 000000000..ef3507966 --- /dev/null +++ b/apps/web/src/components/common/CustomLink/index.tsx @@ -0,0 +1,19 @@ +import MUILink from '@mui/material/Link' +import type { LinkProps as MUILinkProps } from '@mui/material/Link/Link' +import type { LinkProps as NextLinkProps } from 'next/dist/client/link' +import NextLink from 'next/link' + +const CustomLink: React.FC< + React.PropsWithChildren & Pick> +> = ({ href = '', as, children, ...other }) => { + const isExternal = href.toString().startsWith('http') + return ( + + + {children} + + + ) +} + +export default CustomLink diff --git a/src/components/common/CustomTooltip/index.tsx b/apps/web/src/components/common/CustomTooltip/index.tsx similarity index 100% rename from src/components/common/CustomTooltip/index.tsx rename to apps/web/src/components/common/CustomTooltip/index.tsx diff --git a/apps/web/src/components/common/DatePickerInput/index.tsx b/apps/web/src/components/common/DatePickerInput/index.tsx new file mode 100644 index 000000000..4ee9be8a3 --- /dev/null +++ b/apps/web/src/components/common/DatePickerInput/index.tsx @@ -0,0 +1,65 @@ +import { useFormContext, Controller } from 'react-hook-form' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import { DatePicker } from '@mui/x-date-pickers/DatePicker' +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns' +import { isFuture, isValid, startOfDay } from 'date-fns' + +import inputCss from '@/styles/inputs.module.css' + +const DatePickerInput = ({ + name, + label, + deps, + disableFuture = true, + validate, +}: { + name: string + label: string + deps?: string[] + disableFuture?: boolean + validate?: (value: Date | null) => string | undefined +}) => { + const { control } = useFormContext() + + return ( + { + if (!val) { + return + } + + if (!isValid(val)) { + return 'Invalid date' + } + + // Compare days using `startOfDay` to ignore timezone offset + if (disableFuture && isFuture(startOfDay(val))) { + return 'Date cannot be in the future' + } + + return validate?.(val) + }, + }} + render={({ field, fieldState }) => ( + + + + )} + /> + ) +} + +export default DatePickerInput diff --git a/src/components/common/DateTime/DateTime.stories.tsx b/apps/web/src/components/common/DateTime/DateTime.stories.tsx similarity index 100% rename from src/components/common/DateTime/DateTime.stories.tsx rename to apps/web/src/components/common/DateTime/DateTime.stories.tsx diff --git a/src/components/common/DateTime/DateTime.tsx b/apps/web/src/components/common/DateTime/DateTime.tsx similarity index 79% rename from src/components/common/DateTime/DateTime.tsx rename to apps/web/src/components/common/DateTime/DateTime.tsx index 6ce5776e2..b29017a6a 100644 --- a/src/components/common/DateTime/DateTime.tsx +++ b/apps/web/src/components/common/DateTime/DateTime.tsx @@ -9,8 +9,10 @@ type DateTimeProps = { } export const DateTime = ({ value, showDateTime, showTime }: DateTimeProps): ReactElement => { + const showTooltip = !showDateTime || showTime + return ( - + {showTime ? formatTime(value) : showDateTime ? formatDateTime(value) : formatTimeInWords(value)} ) diff --git a/src/components/common/DateTime/DateTimeContainer.tsx b/apps/web/src/components/common/DateTime/DateTimeContainer.tsx similarity index 100% rename from src/components/common/DateTime/DateTimeContainer.tsx rename to apps/web/src/components/common/DateTime/DateTimeContainer.tsx diff --git a/apps/web/src/components/common/DateTime/index.test.tsx b/apps/web/src/components/common/DateTime/index.test.tsx new file mode 100644 index 000000000..d0e07ad03 --- /dev/null +++ b/apps/web/src/components/common/DateTime/index.test.tsx @@ -0,0 +1,96 @@ +import { render } from '@/tests/test-utils' + +import DateTime from '.' +import { formatDateTime, formatTime } from '@/utils/date' +import { useTxFilter } from '@/utils/tx-history-filter' + +jest.mock('@/utils/tx-history-filter', () => ({ + useTxFilter: jest.fn(() => [null, jest.fn()]), +})) + +describe('DateTime', () => { + beforeAll(() => { + // If we do not use a fixed date, this test will fail once a year (in some timezones) due to daylight saving time. + jest.useFakeTimers() + jest.setSystemTime(Date.parse('01.01.2023')) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('should render the relative time before threshold on the queue', () => { + const date = new Date() + const days = 3 + + date.setDate(date.getDate() - days) + + const { queryByText } = render(, { + routerProps: { pathname: '/transactions/queue' }, + }) + + expect(queryByText('3 days ago')).toBeInTheDocument() + }) + + it('should render the full date and time after threshold on the queue', () => { + const date = new Date() + const days = 61 + + date.setDate(date.getDate() - days) + + const { queryByText } = render(, { + routerProps: { pathname: '/transactions/queue' }, + }) + + const expected = formatDateTime(date.getTime()) + + expect(queryByText(expected)).toBeInTheDocument() + }) + + it('should render the time on the history', () => { + const date = new Date() + const days = 1 + + date.setDate(date.getDate() - days) + + const { queryByText } = render(, { + routerProps: { pathname: '/transactions/history' }, + }) + + const expected = formatTime(date.getTime()) + + expect(queryByText(expected)).toBeInTheDocument() + }) + + it('should render the relative time before threshold on the filter', () => { + ;(useTxFilter as jest.Mock).mockImplementation(() => [{ type: 'Incoming', filter: {} }]) + + const date = new Date() + const days = 3 + + date.setDate(date.getDate() - days) + + const { getByText } = render(, { + routerProps: { pathname: '/transactions/history' }, + }) + + expect(getByText('3 days ago')).toBeInTheDocument() + }) + + it('should render the full date and time after threshold on the filter', () => { + ;(useTxFilter as jest.Mock).mockImplementation(() => [{ type: 'Incoming', filter: {} }]) + + const date = new Date() + const days = 61 + + date.setDate(date.getDate() - days) + + const { queryByText } = render(, { + routerProps: { pathname: '/transactions/history' }, + }) + + const expected = formatDateTime(date.getTime()) + + expect(queryByText(expected)).toBeInTheDocument() + }) +}) diff --git a/src/components/common/DateTime/index.tsx b/apps/web/src/components/common/DateTime/index.tsx similarity index 100% rename from src/components/common/DateTime/index.tsx rename to apps/web/src/components/common/DateTime/index.tsx diff --git a/src/components/common/Disclaimer/index.stories.tsx b/apps/web/src/components/common/Disclaimer/index.stories.tsx similarity index 100% rename from src/components/common/Disclaimer/index.stories.tsx rename to apps/web/src/components/common/Disclaimer/index.stories.tsx diff --git a/apps/web/src/components/common/Disclaimer/index.tsx b/apps/web/src/components/common/Disclaimer/index.tsx new file mode 100644 index 000000000..c2c01d4ed --- /dev/null +++ b/apps/web/src/components/common/Disclaimer/index.tsx @@ -0,0 +1,74 @@ +import type { ReactElement, ReactNode } from 'react' +import { Box, Button, Divider, Paper, Stack, SvgIcon, Typography } from '@mui/material' +import InfoIcon from '@/public/images/notifications/info.svg' +import css from './styles.module.css' + +export const Disclaimer = ({ + title, + subtitle, + buttonText, + content, + onAccept, +}: { + title: string + subtitle?: string + buttonText?: string + content: ReactNode + onAccept: () => void +}): ReactElement => { + return ( +
+ + ({ borderBottom: `1px solid ${palette.border.light}` }), + ]} + > + {subtitle && ( + + {subtitle} + + )} + + + + + + {title} + + {content} + + + + + + +
+ ) +} + +export default Disclaimer diff --git a/src/components/common/Disclaimer/styles.module.css b/apps/web/src/components/common/Disclaimer/styles.module.css similarity index 100% rename from src/components/common/Disclaimer/styles.module.css rename to apps/web/src/components/common/Disclaimer/styles.module.css diff --git a/apps/web/src/components/common/EnhancedTable/index.tsx b/apps/web/src/components/common/EnhancedTable/index.tsx new file mode 100644 index 000000000..1be254118 --- /dev/null +++ b/apps/web/src/components/common/EnhancedTable/index.tsx @@ -0,0 +1,196 @@ +import type { ChangeEvent, ReactNode } from 'react' +import React, { useState } from 'react' +import Box from '@mui/material/Box' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import type { SortDirection } from '@mui/material/TableCell' +import TableCell from '@mui/material/TableCell' +import TableContainer from '@mui/material/TableContainer' +import TableHead from '@mui/material/TableHead' +import TablePagination from '@mui/material/TablePagination' +import TableRow from '@mui/material/TableRow' +import TableSortLabel from '@mui/material/TableSortLabel' +import Paper from '@mui/material/Paper' +import { visuallyHidden } from '@mui/utils' +import classNames from 'classnames' + +import css from './styles.module.css' +import { Collapse } from '@mui/material' + +type EnhancedCell = { + content: ReactNode + rawValue: string | number + sticky?: boolean +} + +type EnhancedRow = { + selected?: boolean + collapsed?: boolean + key?: string + cells: Record +} + +type EnhancedHeadCell = { + id: string + label: ReactNode + width?: string + align?: string + sticky?: boolean +} + +function descendingComparator(a: EnhancedRow, b: EnhancedRow, orderBy: string) { + if (b.cells[orderBy].rawValue < a.cells[orderBy].rawValue) { + return -1 + } + if (b.cells[orderBy].rawValue > a.cells[orderBy].rawValue) { + 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) +} + +type EnhancedTableHeadProps = { + headCells: EnhancedHeadCell[] + onRequestSort: (property: string) => void + order: 'asc' | 'desc' + orderBy: string +} + +function EnhancedTableHead(props: EnhancedTableHeadProps) { + const { headCells, order, orderBy, onRequestSort } = props + const createSortHandler = (property: string) => () => { + onRequestSort(property) + } + + return ( + + + {headCells.map((headCell) => ( + + {headCell.label && ( + <> + + {headCell.label} + {orderBy === headCell.id ? ( + + {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + + )} + + ))} + + + ) +} + +export type EnhancedTableProps = { + rows: EnhancedRow[] + headCells: EnhancedHeadCell[] + mobileVariant?: boolean +} + +const pageSizes = [10, 25, 100] + +function EnhancedTable({ rows, headCells, mobileVariant }: EnhancedTableProps) { + const [order, setOrder] = useState<'asc' | 'desc'>('asc') + const [orderBy, setOrderBy] = useState('') + const [page, setPage] = useState(0) + const [rowsPerPage, setRowsPerPage] = useState(pageSizes[1]) + + const handleRequestSort = (property: string) => { + const isAsc = orderBy === property && order === 'asc' + setOrder(isAsc ? 'desc' : 'asc') + setOrderBy(property) + } + + const handleChangePage = (_: any, newPage: number) => { + setPage(newPage) + } + + const handleChangeRowsPerPage = (event: ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value, 10)) + setPage(0) + } + + const orderedRows = orderBy ? rows.slice().sort(getComparator(order, orderBy)) : rows + const pagedRows = orderedRows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + + return ( + + + + + + {pagedRows.length > 0 ? ( + pagedRows.map((row, index) => ( + + {Object.entries(row.cells).map(([key, cell]) => ( + + + {cell.content} + + + ))} + + )) + ) : ( + // Prevent no `tbody` rows hydration error + + + + )} + +
+
+ + {rows.length > pagedRows.length && ( + + )} +
+ ) +} + +export default EnhancedTable diff --git a/src/components/common/EnhancedTable/styles.module.css b/apps/web/src/components/common/EnhancedTable/styles.module.css similarity index 100% rename from src/components/common/EnhancedTable/styles.module.css rename to apps/web/src/components/common/EnhancedTable/styles.module.css diff --git a/apps/web/src/components/common/ErrorBoundary/index.tsx b/apps/web/src/components/common/ErrorBoundary/index.tsx new file mode 100644 index 000000000..8c9238db0 --- /dev/null +++ b/apps/web/src/components/common/ErrorBoundary/index.tsx @@ -0,0 +1,62 @@ +import { Typography, Link } from '@mui/material' + +import { HELP_CENTER_URL, 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' +interface ErrorBoundaryProps { + error: Error + componentStack: string +} + +const ErrorBoundary = ({ error, componentStack }: ErrorBoundaryProps) => { + return ( +
+
+ + Something went wrong, +
+ please try again. +
+ + + + {IS_PRODUCTION ? ( + + In case the problem persists, please reach out to us via our{' '} + Help Center + + ) : ( + <> + {/* Error may be undefined despite what the type says */} + {error?.toString()} + {componentStack} + + )} + + Go home + +
+
+ ) +} + +export default ErrorBoundary diff --git a/src/components/common/ErrorBoundary/styles.module.css b/apps/web/src/components/common/ErrorBoundary/styles.module.css similarity index 100% rename from src/components/common/ErrorBoundary/styles.module.css rename to apps/web/src/components/common/ErrorBoundary/styles.module.css diff --git a/src/components/common/EthHashInfo/SrcEthHashInfo/index.stories.tsx b/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/index.stories.tsx similarity index 100% rename from src/components/common/EthHashInfo/SrcEthHashInfo/index.stories.tsx rename to apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/index.stories.tsx diff --git a/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/index.tsx b/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/index.tsx new file mode 100644 index 000000000..777c8bfba --- /dev/null +++ b/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/index.tsx @@ -0,0 +1,132 @@ +import classnames from 'classnames' +import type { ReactNode, ReactElement, SyntheticEvent } from 'react' +import { isAddress } from '@/utils/rsk-utils' +import { useTheme } from '@mui/material/styles' +import { Box, SvgIcon, Tooltip } from '@mui/material' +import AddressBookIcon from '@/public/images/sidebar/address-book.svg' +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 ImageFallback from '../../ImageFallback' +import css from './styles.module.css' + +export type EthHashInfoProps = { + address: string + chainId?: string + name?: string | null + showAvatar?: boolean + onlyName?: boolean + showCopyButton?: boolean + prefix?: string + showPrefix?: boolean + copyPrefix?: boolean + shortAddress?: boolean + copyAddress?: boolean + customAvatar?: string + hasExplorer?: boolean + avatarSize?: number + children?: ReactNode + trusted?: boolean + ExplorerButtonProps?: ExplorerButtonProps + isAddressBookName?: boolean +} + +const stopPropagation = (e: SyntheticEvent) => e.stopPropagation() + +const SrcEthHashInfo = ({ + address, + customAvatar, + prefix = '', + copyPrefix = true, + showPrefix = true, + shortAddress = true, + copyAddress = true, + showAvatar = true, + onlyName = false, + avatarSize, + name, + showCopyButton, + hasExplorer, + ExplorerButtonProps, + children, + trusted = true, + isAddressBookName = false, +}: EthHashInfoProps): ReactElement => { + const shouldPrefix = isAddress(address) + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + const identicon = + const shouldCopyPrefix = shouldPrefix && copyPrefix + + const addressElement = ( + <> + {showPrefix && shouldPrefix && prefix && {prefix}:} + {shortAddress || isMobile ? shortenAddress(address) : address} + + ) + + return ( +
+ {showAvatar && ( +
+ {customAvatar ? ( + + ) : ( + identicon + )} +
+ )} + + + {name && ( + + + {name} + + + {isAddressBookName && ( + + + + + + )} + + )} + +
+ {(!onlyName || !name) && ( + + {copyAddress ? ( + + {addressElement} + + ) : ( + addressElement + )} + + )} + + {showCopyButton && ( + + )} + + {hasExplorer && ExplorerButtonProps && ( + + + + )} + + {children} +
+
+
+ ) +} + +export default SrcEthHashInfo diff --git a/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/styles.module.css b/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/styles.module.css new file mode 100644 index 000000000..1f755478a --- /dev/null +++ b/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/styles.module.css @@ -0,0 +1,28 @@ +.container { + display: flex; + align-items: center; + gap: 0.5em; + line-height: 1.4; + width: 100%; +} + +.avatarContainer { + flex-shrink: 0; + position: relative; +} + +.avatarContainer > * { + width: 100% !important; + height: 100% !important; +} + +.addressContainer { + display: flex; + align-items: center; + white-space: nowrap; +} + +.inline { + display: flex; + align-items: center; +} diff --git a/src/components/common/EthHashInfo/index.stories.tsx b/apps/web/src/components/common/EthHashInfo/index.stories.tsx similarity index 100% rename from src/components/common/EthHashInfo/index.stories.tsx rename to apps/web/src/components/common/EthHashInfo/index.stories.tsx diff --git a/apps/web/src/components/common/EthHashInfo/index.test.tsx b/apps/web/src/components/common/EthHashInfo/index.test.tsx new file mode 100644 index 000000000..3ae9e9282 --- /dev/null +++ b/apps/web/src/components/common/EthHashInfo/index.test.tsx @@ -0,0 +1,417 @@ +import { blo } from 'blo' +import { act } from 'react' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { fireEvent, render, waitFor } from '@/tests/test-utils' +import * as useAllAddressBooks from '@/hooks/useAllAddressBooks' +import * as useChainId from '@/hooks/useChainId' +import * as store from '@/store' +import EthHashInfo from '.' + +const originalClipboard = { ...global.navigator.clipboard } + +const MOCK_SAFE_ADDRESS = '0x0000000000000000000000000000000000005AFE' +const MOCK_CHAIN_ID = '4' + +jest.mock('@/hooks/useAllAddressBooks') +jest.mock('@/hooks/useChainId') + +describe('EthHashInfo', () => { + beforeEach(() => { + jest.clearAllMocks() + + jest.spyOn(useAllAddressBooks, 'default').mockImplementation(() => ({ + [MOCK_CHAIN_ID]: { + [MOCK_SAFE_ADDRESS]: 'Address book name', + }, + })) + + //@ts-ignore + global.navigator.clipboard = { + writeText: jest.fn(() => Promise.resolve()), + } + }) + + afterEach(() => { + //@ts-ignore + global.navigator.clipboard = originalClipboard + }) + + describe('address', () => { + it('renders a shortened address by default', () => { + const { queryAllByText } = render() + + expect(queryAllByText('0x0000...5AFE')[0]).toBeInTheDocument() + }) + + it('renders a full address', () => { + const { queryByText } = render() + + expect(queryByText(MOCK_SAFE_ADDRESS)).toBeInTheDocument() + }) + }) + + describe('prefix', () => { + it('renders the current chain prefix by default', () => { + jest.spyOn(useChainId, 'default').mockReturnValue('4') + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + session: {}, + settings: { + shortName: { + copy: true, + }, + }, + chains: { + data: [{ chainId: '4', shortName: 'rin' }], + }, + } as store.RootState), + ) + + const { queryByText } = render() + + expect(queryByText('rin:')).toBeInTheDocument() + }) + + it('renders the chain prefix associated with the given chainId', () => { + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + session: {}, + settings: { + shortName: { + copy: true, + }, + }, + chains: { + data: [ + { chainId: '4', shortName: 'rin' }, + { chainId: '100', shortName: 'gno' }, + ], + }, + } as store.RootState), + ) + + const { queryByText } = render() + + expect(queryByText('gno:')).toBeInTheDocument() + }) + + it('renders a custom prefix', () => { + jest.spyOn(store, 'useAppSelector').mockReturnValue({ + shortName: { + copy: true, + }, + }) + + const { queryByText } = render() + + expect(queryByText('test:')).toBeInTheDocument() + }) + + it("doesn't prefix non-addresses", () => { + jest.spyOn(useChainId, 'default').mockReturnValue('4') + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + session: {}, + settings: { + shortName: { + copy: true, + }, + }, + chains: { + data: [{ chainId: '4', shortName: 'rin' }], + }, + } as store.RootState), + ) + + const result1 = render( + , + ) + + expect(result1.queryByText('rin:')).not.toBeInTheDocument() + + const result2 = render() + + expect(result2.queryByText('rin:')).not.toBeInTheDocument() + }) + + it('should not render the prefix when disabled in the props', () => { + const { queryByText } = render() + + expect(queryByText('rin:')).not.toBeInTheDocument() + }) + }) + + describe('name', () => { + it('renders a name by default', () => { + const { queryByText } = render() + + expect(queryByText('Test name')).toBeInTheDocument() + }) + + it('renders a name from the address book even if a name is passed', () => { + const { queryByText } = render() + + expect(queryByText('Address book name')).toBeInTheDocument() + }) + + it('renders a name from the address book', () => { + const { queryByText } = render() + + expect(queryByText('Address book name')).toBeInTheDocument() + }) + + it('hides a name', () => { + const { queryByText } = render() + + expect(queryByText('Test')).not.toBeInTheDocument() + expect(queryByText('Address book name')).not.toBeInTheDocument() + }) + }) + + describe('avatar', () => { + it('renders an avatar by default', () => { + const { container } = render() + + expect(container.querySelector('.icon')).toHaveAttribute( + 'style', + `background-image: url(${blo(MOCK_SAFE_ADDRESS)}); width: 40px; height: 40px;`, + ) + }) + + it('allows for sizing of avatars', () => { + const { container } = render() + + expect(container.querySelector('.icon')).toHaveAttribute( + 'style', + `background-image: url(${blo(MOCK_SAFE_ADDRESS)}); width: 100px; height: 100px;`, + ) + }) + + it('renders a custom avatar', () => { + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute('src', './test.jpg') + }) + + it('allows for sizing of custom avatars', () => { + const { container } = render( + , + ) + + const avatar = container.querySelector('img') + + expect(avatar).toHaveAttribute('src', './test.jpg') + expect(avatar).toHaveAttribute('width', '100') + expect(avatar).toHaveAttribute('height', '100') + }) + + it('falls back to an identicon', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelector('.icon')).toBeInTheDocument() + }) + }) + + it('hides the avatar', () => { + const { container } = render() + + expect(container.querySelector('.icon')).not.toBeInTheDocument() + }) + }) + + describe('copy button', () => { + it("doesn't show the copy button by default", () => { + const { container } = render() + + expect(container.querySelector('button')).not.toBeInTheDocument() + }) + + it('shows the copy button', () => { + const { container } = render() + + expect(container.querySelector('button')).toBeInTheDocument() + }) + + it("doesn't copy the prefix with non-addresses", async () => { + jest.spyOn(useChainId, 'default').mockReturnValue('4') + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + session: {}, + settings: { + shortName: { + copy: true, + }, + }, + chains: { + data: [{ chainId: '4', shortName: 'rin' }], + }, + } as store.RootState), + ) + + const { container } = render( + , + ) + + const button = container.querySelector('button') + + act(() => { + fireEvent.click(button!) + }) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + '0xe26920604f9a02c5a877d449faa71b7504f0c2508dcc7c0384078a024b8e592f', + ) + }) + + it('copies the default prefixed address', async () => { + jest.spyOn(useChainId, 'default').mockReturnValue('4') + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + session: {}, + settings: { + shortName: { + copy: true, + }, + }, + chains: { + data: [{ chainId: '4', shortName: 'rin' }], + }, + } as store.RootState), + ) + + const { container } = render() + + const button = container.querySelector('button') + + act(() => { + fireEvent.click(button!) + }) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`rin:${MOCK_SAFE_ADDRESS}`) + }) + + it('copies the prefix even if it is hidden', async () => { + jest.spyOn(useChainId, 'default').mockReturnValue('4') + + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + session: {}, + settings: { + shortName: { + copy: true, + }, + }, + chains: { + data: [{ chainId: '4', shortName: 'rin' }], + }, + } as store.RootState), + ) + + const { container, queryByText } = render( + , + ) + + expect(queryByText('rin:')).not.toBeInTheDocument() + + const button = container.querySelector('button') + + act(() => { + fireEvent.click(button!) + }) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`rin:${MOCK_SAFE_ADDRESS}`) + }) + + it('copies the selected chainId prefix', async () => { + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + session: {}, + settings: { + shortName: { + copy: true, + }, + }, + chains: { + data: [ + { chainId: '4', shortName: 'rin' }, + { chainId: '100', shortName: 'gno' }, + ], + }, + } as store.RootState), + ) + + const { container } = render() + + const button = container.querySelector('button') + + act(() => { + fireEvent.click(button!) + }) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`gno:${MOCK_SAFE_ADDRESS}`) + }) + + it('copies the raw address', async () => { + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + session: {}, + settings: { + shortName: { + copy: false, + }, + }, + chains: { + data: [] as ChainInfo[], + }, + } as store.RootState), + ) + + const { container } = render() + + const button = container.querySelector('button') + + act(() => { + fireEvent.click(button!) + }) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(MOCK_SAFE_ADDRESS) + }) + }) + + describe('block explorer', () => { + it("doesn't render the block explorer link by default", () => { + const { container } = render() + + expect(container.querySelector('a')).not.toBeInTheDocument() + }) + it('renders the block explorer link', () => { + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => + selector({ + session: {}, + settings: { shortName: {} }, + chains: { + data: [ + { + chainId: '4', + blockExplorerUriTemplate: { address: 'https://rinkeby.etherscan.io/address/{{address}}' }, + }, + ], + }, + } as store.RootState), + ) + + const { container } = render() + + expect(container.querySelector('a')).toHaveAttribute( + 'href', + 'https://rinkeby.etherscan.io/address/0x0000000000000000000000000000000000005AFE', + ) + }) + }) +}) diff --git a/apps/web/src/components/common/EthHashInfo/index.tsx b/apps/web/src/components/common/EthHashInfo/index.tsx new file mode 100644 index 000000000..46331daa4 --- /dev/null +++ b/apps/web/src/components/common/EthHashInfo/index.tsx @@ -0,0 +1,54 @@ +import { type ReactElement, useMemo } from 'react' +import { useChain } from '@/hooks/useChains' +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 SrcEthHashInfo, { type EthHashInfoProps } from './SrcEthHashInfo' +import { toChecksumAddress, isAddress } from '@/utils/rsk-utils' + +const EthHashInfo = ({ + showName = true, + avatarSize = 40, + ...props +}: EthHashInfoProps & { showName?: boolean }): ReactElement => { + const settings = useAppSelector(selectSettings) + const currentChainId = useChainId() + const chain = useChain(props.chainId || currentChainId) + const addressBooks = useAllAddressBooks() + + // Always use toChecksumAddress for Rootstock + const address = useMemo(() => { + // First validate if it's a valid address + if (!isAddress(props.address)) { + return props.address + } + // Then apply our checksum + return toChecksumAddress(props.address, currentChainId) + }, [props.address, currentChainId]) + + const link = chain && props.hasExplorer ? getBlockExplorerLink(chain, address) : undefined + const name = showName && chain ? addressBooks?.[chain.chainId]?.[address] || props.name : undefined + // const link = chain && props.hasExplorer ? getBlockExplorerLink(chain, props.address) : undefined + // const addressBookName = chain ? addressBooks?.[chain.chainId]?.[props.address] : undefined + // const name = showName ? addressBookName || props.name : undefined + + return ( + + {props.children} + + ) +} + +export default EthHashInfo diff --git a/apps/web/src/components/common/ExplorerButton/index.tsx b/apps/web/src/components/common/ExplorerButton/index.tsx new file mode 100644 index 000000000..f10732051 --- /dev/null +++ b/apps/web/src/components/common/ExplorerButton/index.tsx @@ -0,0 +1,60 @@ +import type { ReactElement, ComponentType, SyntheticEvent } from 'react' +import { Box, IconButton, SvgIcon, Tooltip, Typography } from '@mui/material' +import LinkIcon from '@/public/images/common/link.svg' +import Link from 'next/link' + +export type ExplorerButtonProps = { + title?: string + href?: string + className?: string + icon?: ComponentType + onClick?: (e: SyntheticEvent) => void + isCompact?: boolean +} + +const ExplorerButton = ({ + title = '', + href = '', + icon = LinkIcon, + className, + onClick, + isCompact = true, +}: ExplorerButtonProps): ReactElement | null => { + if (!href) return null + + return isCompact ? ( + + + + + + ) : ( + + + + View on explorer + + + + + + ) +} + +export default ExplorerButton diff --git a/apps/web/src/components/common/ExternalLink/index.tsx b/apps/web/src/components/common/ExternalLink/index.tsx new file mode 100644 index 000000000..c32b9f800 --- /dev/null +++ b/apps/web/src/components/common/ExternalLink/index.tsx @@ -0,0 +1,43 @@ +import type { ReactElement } from 'react' +import { OpenInNewRounded } from '@mui/icons-material' +import { Box, Button, Link, type LinkProps } from '@mui/material' + +/** + * Renders an external Link which always sets the noopener and noreferrer rel attribute and the target to _blank. + * It also always adds the external link icon as end adornment. + */ +const ExternalLink = ({ + noIcon = false, + children, + href, + mode = 'link', + ...props +}: Omit & { noIcon?: boolean; mode?: 'button' | 'link' }): ReactElement => { + if (!href) return <>{children} + + const linkContent = ( + + {children} + {!noIcon && } + + ) + return mode === 'link' ? ( + + {linkContent} + + ) : ( + + ) +} + +export default ExternalLink diff --git a/apps/web/src/components/common/FiatValue/FiatValue.test.tsx b/apps/web/src/components/common/FiatValue/FiatValue.test.tsx new file mode 100644 index 000000000..c4d6e8ce2 --- /dev/null +++ b/apps/web/src/components/common/FiatValue/FiatValue.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@/tests/test-utils' + +const normalizer = (text: string) => text.replace(/\u200A/g, ' ') + +describe('FiatValue', () => { + beforeEach(() => { + Object.defineProperty(window, 'navigator', { + value: { + language: 'en-US', + }, + writable: true, + }) + }) + + it('should render fiat value', () => { + const FiatValue = require('.').default + const { getByText } = render() + const span = getByText((content) => normalizer(content) === '$ 100', { normalizer }) + expect(span).toBeInTheDocument() + expect(span).toHaveAttribute('aria-label', '$ 100.00') + }) + + it('should render a big fiat value', () => { + const FiatValue = require('.').default + const { getByText } = render() + const span = getByText((content) => normalizer(content) === '$ 100.29M', { normalizer }) + expect(span).toBeInTheDocument() + expect(span).toHaveAttribute('aria-label', '$ 100,285,367.00') + }) + + it('should render fiat value with precise=true', () => { + const FiatValue = require('.').default + const { getByText } = render() + expect(getByText((content) => normalizer(content) === '$ 100', { normalizer })).toBeInTheDocument() + expect(getByText('.35')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/common/FiatValue/index.tsx b/apps/web/src/components/common/FiatValue/index.tsx new file mode 100644 index 000000000..d934a7b28 --- /dev/null +++ b/apps/web/src/components/common/FiatValue/index.tsx @@ -0,0 +1,55 @@ +import type { CSSProperties, ReactElement } from 'react' +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' + +const style = { whiteSpace: 'nowrap' } as CSSProperties + +const FiatValue = ({ + value, + maxLength, + precise, +}: { + value: string | number + maxLength?: number + precise?: boolean +}): ReactElement => { + const currency = useAppSelector(selectCurrency) + + const fiat = useMemo(() => { + return formatCurrency(value, currency, maxLength) + }, [value, currency, maxLength]) + + const preciseFiat = useMemo(() => { + return formatCurrencyPrecise(value, currency) + }, [value, currency]) + + const [whole, decimals, endCurrency] = useMemo(() => { + const match = preciseFiat.match(/(.+)(\D\d+)(\D+)?$/) + return match ? match.slice(1) : ['', preciseFiat, '', ''] + }, [preciseFiat]) + + return ( + + + {precise ? ( + <> + {whole} + {decimals && ( + + {decimals} + + )} + {endCurrency} + + ) : ( + fiat + )} + + + ) +} + +export default FiatValue diff --git a/apps/web/src/components/common/FileUpload/index.tsx b/apps/web/src/components/common/FileUpload/index.tsx new file mode 100644 index 000000000..7ccd6315b --- /dev/null +++ b/apps/web/src/components/common/FileUpload/index.tsx @@ -0,0 +1,172 @@ +import css from './styles.module.css' +import { Box, Grid, IconButton, Link, SvgIcon, type SvgIconTypeMap, Typography } from '@mui/material' +import HighlightOffIcon from '@mui/icons-material/HighlightOff' +import FileIcon from '@/public/images/settings/data/file.svg' +import type { MouseEventHandler, ReactElement } from 'react' +import type { DropzoneInputProps, DropzoneRootProps } from 'react-dropzone' + +export type FileInfo = { + name: string + additionalInfo?: string + summary: ReactElement[] + error?: string +} + +export enum FileTypes { + JSON = 'JSON', + CSV = 'CSV', +} + +const ColoredFileIcon = ({ color }: { color: SvgIconTypeMap['props']['color'] }) => ( + +) + +const UploadSummary = ({ fileInfo, onRemove }: { fileInfo: FileInfo; onRemove: (() => void) | MouseEventHandler }) => { + return ( + + + + + + + {fileInfo.name} + {fileInfo.additionalInfo && ` - ${fileInfo.additionalInfo}`} + + + + + + + + + +
+ + <> + {fileInfo.summary.map((summaryItem, idx) => ( + + + + + + {summaryItem} + + + ))} + {fileInfo.error && ( + + + + + + + {fileInfo.error} + + + + )} + + + ) +} + +const FileUpload = ({ + getRootProps, + getInputProps, + isDragReject = false, + isDragActive = false, + fileType, + fileInfo, + onRemove, +}: { + isDragReject?: boolean + isDragActive?: boolean + fileType: FileTypes + getInputProps?: (props?: T | undefined) => T + getRootProps: (props?: T | undefined) => T + fileInfo?: FileInfo + onRemove: (() => void) | MouseEventHandler +}) => { + if (fileInfo) { + return + } + return ( + `${isDragReject ? palette.error.light : undefined} !important`, + border: ({ palette }) => + `1px dashed ${ + isDragReject ? palette.error.dark : isDragActive ? palette.primary.main : palette.secondary.dark + }`, + }} + > + {getInputProps && } + + palette.primary.light }} + /> + + Drag and drop a {fileType} file or choose a file + + + + ) +} + +export default FileUpload diff --git a/apps/web/src/components/common/FileUpload/styles.module.css b/apps/web/src/components/common/FileUpload/styles.module.css new file mode 100644 index 000000000..2189462c0 --- /dev/null +++ b/apps/web/src/components/common/FileUpload/styles.module.css @@ -0,0 +1,23 @@ +.dropbox { + align-items: center; + border-radius: 8px; + display: flex; + flex-direction: column; + justify-content: center; + cursor: pointer; + padding: var(--space-3) var(--space-5); + margin: var(--space-3) 0; + background: var(--color-secondary-background); + color: var(--color-primary-light); + transition: + border 0.5s, + background 0.5s; +} + +.verticalLine { + display: flex; + height: 18px; + border-right: 1px solid var(--color-primary-main); + margin-left: 7px; + margin-top: -8px; +} diff --git a/apps/web/src/components/common/Footer/index.tsx b/apps/web/src/components/common/Footer/index.tsx new file mode 100644 index 000000000..d3fa64fac --- /dev/null +++ b/apps/web/src/components/common/Footer/index.tsx @@ -0,0 +1,89 @@ +import type { ReactElement, ReactNode } from 'react' +import { SvgIcon, Typography } from '@mui/material' +import GitHubIcon from '@mui/icons-material/GitHub' +import Link from 'next/link' +import { useRouter } from 'next/router' +import css from './styles.module.css' +import { AppRoutes } from '@/config/routes' +import packageJson from '../../../../package.json' +//import AppstoreButton from '../AppStoreButton' +import ExternalLink from '../ExternalLink' +import MUILink from '@mui/material/Link' +import { HELP_CENTER_URL } from '@/config/constants' +import darkPalette from '@/components/theme/darkPalette' +import ProtofireLogo from '@/public/images/protofire-logo.svg' + +const footerPages = [ + AppRoutes.welcome.index, + AppRoutes.settings.index, + AppRoutes.imprint, + AppRoutes.privacy, + AppRoutes.cookie, + AppRoutes.terms, + AppRoutes.licenses, +] + +const FooterLink = ({ children, href }: { children: ReactNode; href: string }): ReactElement => { + return href ? ( + + {children} + + ) : ( + {children} + ) +} + +const Footer = (): ReactElement | null => { + const router = useRouter() + + if (!footerPages.some((path) => router.pathname.startsWith(path))) { + return null + } + + const getHref = (path: string): string => { + return router.pathname === path ? '' : path + } + + return ( +
+
    +
  • + Terms +
  • +
  • + Cookie policy +
  • +
  • + Preferences +
  • +
  • + + Help + +
  • + +
  • + + v{packageJson.version} + +
  • +
  • + + Supported by{' '} + + + Protofire + + +
  • +
+
+ ) +} + +export default Footer diff --git a/src/components/common/Footer/styles.module.css b/apps/web/src/components/common/Footer/styles.module.css similarity index 100% rename from src/components/common/Footer/styles.module.css rename to apps/web/src/components/common/Footer/styles.module.css diff --git a/apps/web/src/components/common/GeoblockingProvider/index.tsx b/apps/web/src/components/common/GeoblockingProvider/index.tsx new file mode 100644 index 000000000..47e660508 --- /dev/null +++ b/apps/web/src/components/common/GeoblockingProvider/index.tsx @@ -0,0 +1,21 @@ +import { AppRoutes } from '@/config/routes' +import useAsync from '@/hooks/useAsync' +import { createContext, type ReactElement, type ReactNode } from 'react' + +export const GeoblockingContext = createContext(null) + +const checkBlocked = async () => { + const res = await fetch(AppRoutes.swap, { method: 'HEAD' }) + return res.status === 403 +} + +/** + * Endpoint returns a 403 if the requesting user is from one of the OFAC sanctioned countries + */ +const GeoblockingProvider = ({ children }: { children: ReactNode }): ReactElement => { + const [isBlockedCountry = null] = useAsync(checkBlocked, []) + + return {children} +} + +export default GeoblockingProvider diff --git a/apps/web/src/components/common/Header/index.test.tsx b/apps/web/src/components/common/Header/index.test.tsx new file mode 100644 index 000000000..53da98fbd --- /dev/null +++ b/apps/web/src/components/common/Header/index.test.tsx @@ -0,0 +1,133 @@ +import Header from '@/components/common/Header/index' +import * as useChains from '@/hooks/useChains' +import * as useIsSafeOwner from '@/hooks/useIsSafeOwner' +import * as useProposers from '@/hooks/useProposers' +import * as useSafeAddress from '@/hooks/useSafeAddress' +import * as useSafeTokenEnabled from '@/hooks/useSafeTokenEnabled' +import { render } from '@/tests/test-utils' +import { faker } from '@faker-js/faker' +import { screen, fireEvent } from '@testing-library/react' + +jest.mock( + '@/components/common/SafeTokenWidget', + () => + function SafeTokenWidget() { + return
SafeTokenWidget
+ }, +) + +jest.mock( + '@/features/walletconnect/components', + () => + function WalletConnect() { + return
WalletConnect
+ }, +) + +jest.mock( + '@/components/common/NetworkSelector', + () => + function NetworkSelector() { + return
NetworkSelector
+ }, +) + +jest.mock('@/hooks/useIsOfficialHost', () => ({ + useIsOfficialHost: () => true, +})) + +describe('Header', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('renders the menu button when onMenuToggle is provided', () => { + render(
) + expect(screen.getByLabelText('menu')).toBeInTheDocument() + }) + + it('does not render the menu button when onMenuToggle is not provided', () => { + render(
) + expect(screen.queryByLabelText('menu')).not.toBeInTheDocument() + }) + + it('calls onMenuToggle when menu button is clicked', () => { + const onMenuToggle = jest.fn() + render(
) + + const menuButton = screen.getByLabelText('menu') + fireEvent.click(menuButton) + + expect(onMenuToggle).toHaveBeenCalled() + }) + + it('renders the SafeTokenWidget when showSafeToken is true', () => { + jest.spyOn(useSafeTokenEnabled, 'useSafeTokenEnabled').mockReturnValue(true) + + render(
) + expect(screen.getByText('SafeTokenWidget')).toBeInTheDocument() + }) + + it('does not render the SafeTokenWidget when showSafeToken is false', () => { + jest.spyOn(useSafeTokenEnabled, 'useSafeTokenEnabled').mockReturnValue(false) + + render(
) + expect(screen.queryByText('SafeTokenWidget')).not.toBeInTheDocument() + }) + + it('displays the safe logo', () => { + render(
) + expect(screen.getAllByAltText('Safe logo')[0]).toBeInTheDocument() + }) + + it('renders the BatchIndicator when showBatchButton is true', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue(faker.finance.ethereumAddress()) + jest.spyOn(useProposers, 'useIsWalletProposer').mockReturnValue(false) + jest.spyOn(useIsSafeOwner, 'default').mockReturnValue(false) + + render(
) + expect(screen.getByTitle('Batch')).toBeInTheDocument() + }) + + it('does not render the BatchIndicator when there is no safe address', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue('') + + render(
) + expect(screen.queryByTitle('Batch')).not.toBeInTheDocument() + }) + + it('does not render the BatchIndicator when connected wallet is a proposer', () => { + jest.spyOn(useProposers, 'useIsWalletProposer').mockReturnValue(true) + + render(
) + expect(screen.queryByTitle('Batch')).not.toBeInTheDocument() + }) + + it('renders the WalletConnect component when enableWc is true', () => { + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + + render(
) + expect(screen.getByText('WalletConnect')).toBeInTheDocument() + }) + + it('does not render the WalletConnect component when enableWc is false', () => { + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(false) + + render(
) + expect(screen.queryByText('WalletConnect')).not.toBeInTheDocument() + }) + + it('renders the NetworkSelector when safeAddress exists', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue(faker.finance.ethereumAddress()) + + render(
) + expect(screen.getByText('NetworkSelector')).toBeInTheDocument() + }) + + it('does not render the NetworkSelector when safeAddress is falsy', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue('') + + render(
) + expect(screen.queryByText('NetworkSelector')).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/common/Header/index.tsx b/apps/web/src/components/common/Header/index.tsx new file mode 100644 index 000000000..791cfa5de --- /dev/null +++ b/apps/web/src/components/common/Header/index.tsx @@ -0,0 +1,130 @@ +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useIsWalletProposer } from '@/hooks/useProposers' +import type { Dispatch, SetStateAction } from 'react' +import { type ReactElement } from 'react' +import { useRouter } from 'next/router' +import type { Url } from 'next/dist/shared/lib/router/router' +import { IconButton, Paper } from '@mui/material' +import MenuIcon from '@mui/icons-material/Menu' +import classnames from 'classnames' +import css from './styles.module.css' +import ConnectWallet from '@/components/common/ConnectWallet' +import NetworkSelector from '@/components/common/NetworkSelector' +import SafeTokenWidget from '@/components/common/SafeTokenWidget' +import NotificationCenter from '@/components/notification-center/NotificationCenter' +import { AppRoutes } from '@/config/routes' +import SafeLogo from '@/public/images/logo.svg' +import SafeLogoMobile from '@/public/images/logo-no-text.svg' +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 { useIsOfficialHost } from '@/hooks/useIsOfficialHost' +import { BRAND_NAME } from '@/config/constants' + +type HeaderProps = { + onMenuToggle?: Dispatch> + onBatchToggle?: Dispatch> +} + +function getLogoLink(router: ReturnType): Url { + return router.pathname === AppRoutes.home || !router.query.safe + ? router.pathname === AppRoutes.welcome.accounts + ? AppRoutes.welcome.index + : AppRoutes.welcome.accounts + : { pathname: AppRoutes.home, query: { safe: router.query.safe } } +} + +const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => { + const safeAddress = useSafeAddress() + const showSafeToken = useSafeTokenEnabled() + const isProposer = useIsWalletProposer() + const isSafeOwner = useIsSafeOwner() + const router = useRouter() + const enableWc = useHasFeature(FEATURES.NATIVE_WALLETCONNECT) + //const isOfficialHost = useIsOfficialHost() + + // If on the home page, the logo should link to the Accounts or Welcome page, otherwise to the home page + const logoHref = getLogoLink(router) + + const handleMenuToggle = () => { + if (onMenuToggle) { + onMenuToggle((isOpen) => !isOpen) + } else { + router.push(logoHref) + } + } + + const handleBatchToggle = () => { + if (onBatchToggle) { + onBatchToggle((isOpen) => !isOpen) + } + } + + const showBatchButton = safeAddress && (!isProposer || isSafeOwner) + + return ( + +
+ {onMenuToggle && ( + + + + )} +
+ +
+ + + +
+ +
+ + + +
+ + {showSafeToken && ( +
+ +
+ )} + +
+ +
+ + {showBatchButton && ( +
+ +
+ )} + + {enableWc && ( +
+ +
+ )} + +
+ + + +
+ + {safeAddress && ( +
+ +
+ )} +
+ ) +} + +export default Header diff --git a/apps/web/src/components/common/Header/styles.module.css b/apps/web/src/components/common/Header/styles.module.css new file mode 100644 index 000000000..47f1fda35 --- /dev/null +++ b/apps/web/src/components/common/Header/styles.module.css @@ -0,0 +1,91 @@ +.container { + height: var(--header-height); + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + position: relative; + border-radius: 0 !important; + background-color: var(--color-background-paper); + border-bottom: 1px solid var(--color-border-light); +} + +.element { + height: 100%; + border-right: 1px solid var(--color-border-light); + display: flex; + flex-direction: column; + justify-content: center; +} + +.element :global(.MuiBadge-standard) { + font-size: 12px; + width: 18px; + height: 18px; + min-width: 18px; +} + +[data-theme='dark'] .element :global(.MuiBadge-standard) { + background-color: var(--color-primary-main); +} + +.menuButton, +.logo { + flex: 1; + border: none; + align-items: flex-start; +} + +.logoMobile { + display: none; +} + +.logo img, +.logo svg, +.logoMobile svg { + width: auto; + display: block; + color: var(--color-logo-main); + height: 30px; +} + +.logo { + padding: var(--space-2); +} + +.menuButton { + display: none; +} + +.networkSelector { + border-right: none; +} + +.connectWallet { + flex-shrink: 0; +} + +@media (max-width: 899.95px) { + .logo { + display: none; + } + + .logoMobile { + display: flex; + flex: 1; + border: none; + align-items: flex-start; + margin-left: var(--space-2); + } + + .menuButton { + display: flex; + flex: 0; + } +} + +@media (max-width: 599.95px) { + .hideMobile { + display: none; + } +} diff --git a/apps/web/src/components/common/Identicon/index.tsx b/apps/web/src/components/common/Identicon/index.tsx new file mode 100644 index 000000000..16ed8428f --- /dev/null +++ b/apps/web/src/components/common/Identicon/index.tsx @@ -0,0 +1,40 @@ +import type { ReactElement, CSSProperties } from 'react' +import { useMemo } from 'react' +import { blo } from 'blo' +import Skeleton from '@mui/material/Skeleton' + +import css from './styles.module.css' +import { isAddress } from 'ethers' +import { toChecksumAddress } from '@/utils/rsk-utils' + +export interface IdenticonProps { + address: string + size?: number +} + +const Identicon = ({ address, size = 40 }: IdenticonProps): ReactElement => { + const style = useMemo(() => { + const checksumAddress = toChecksumAddress(address) + try { + if (!isAddress(checksumAddress)) { + return null + } + const blockie = blo(address as `0x${string}`) + return { + backgroundImage: `url(${blockie})`, + width: `${size}px`, + height: `${size}px`, + } + } catch (e) { + return null + } + }, [address, size]) + + return !style ? ( + + ) : ( +
+ ) +} + +export default Identicon diff --git a/src/components/common/Identicon/styles.module.css b/apps/web/src/components/common/Identicon/styles.module.css similarity index 100% rename from src/components/common/Identicon/styles.module.css rename to apps/web/src/components/common/Identicon/styles.module.css diff --git a/src/components/common/ImageFallback/index.tsx b/apps/web/src/components/common/ImageFallback/index.tsx similarity index 100% rename from src/components/common/ImageFallback/index.tsx rename to apps/web/src/components/common/ImageFallback/index.tsx diff --git a/src/components/common/InfiniteScroll/index.tsx b/apps/web/src/components/common/InfiniteScroll/index.tsx similarity index 100% rename from src/components/common/InfiniteScroll/index.tsx rename to apps/web/src/components/common/InfiniteScroll/index.tsx diff --git a/src/components/common/InputValueHelper/index.tsx b/apps/web/src/components/common/InputValueHelper/index.tsx similarity index 100% rename from src/components/common/InputValueHelper/index.tsx rename to apps/web/src/components/common/InputValueHelper/index.tsx diff --git a/apps/web/src/components/common/LegalDisclaimerContent/index.tsx b/apps/web/src/components/common/LegalDisclaimerContent/index.tsx new file mode 100644 index 000000000..0fed91389 --- /dev/null +++ b/apps/web/src/components/common/LegalDisclaimerContent/index.tsx @@ -0,0 +1,43 @@ +import ExternalLink from '@/components/common/ExternalLink' +import { AppRoutes } from '@/config/routes' +import { Typography } from '@mui/material' +import { type ReactElement } from 'react' +import css from './styles.module.css' + +const LegalDisclaimerContent = ({ + withTitle = true, + isSafeApps = true, +}: { + withTitle?: boolean + isSafeApps?: boolean +}): ReactElement => ( +
+ {withTitle && ( + + Disclaimer + + )} +
+ + You are now accessing {isSafeApps ? 'third-party apps' : 'a third-party app'}, which we do not own, control, + maintain or audit. We are not liable for any loss you may suffer in connection with interacting with the{' '} + {isSafeApps ? 'apps' : 'app'}, which is at your own risk. + + + + You must read our Terms, which contain more detailed provisions binding on you relating to the{' '} + {isSafeApps ? 'apps' : 'app'}. + + + + I have read and understood the{' '} + + Terms + {' '} + and this Disclaimer, and agree to be bound by them. + +
+
+) + +export default LegalDisclaimerContent diff --git a/src/components/common/LegalDisclaimerContent/styles.module.css b/apps/web/src/components/common/LegalDisclaimerContent/styles.module.css similarity index 100% rename from src/components/common/LegalDisclaimerContent/styles.module.css rename to apps/web/src/components/common/LegalDisclaimerContent/styles.module.css diff --git a/apps/web/src/components/common/MetaTags/index.tsx b/apps/web/src/components/common/MetaTags/index.tsx new file mode 100644 index 000000000..e9a01067f --- /dev/null +++ b/apps/web/src/components/common/MetaTags/index.tsx @@ -0,0 +1,50 @@ +import { BRAND_NAME, IS_PRODUCTION } from '@/config/constants' +import { ContentSecurityPolicy, StrictTransportSecurity } from '@/config/securityHeaders' +import lightPalette from '@/components/theme/lightPalette' +import darkPalette from '@/components/theme/darkPalette' + +const descriptionText = `${BRAND_NAME} is the most trusted smart account wallet on Ethereum with over $100B secured.` +const titleText = BRAND_NAME + +const MetaTags = ({ prefetchUrl }: { prefetchUrl: string }) => ( + <> + + {!IS_PRODUCTION && } + + {/* Social sharing */} + + + + + + + + + + {/* CSP */} + + {IS_PRODUCTION && } + + {/* Prefetch the backend domain */} + + + + {/* Mobile tags */} + + + + {/* PWA primary color and manifest */} + + + + + {/* Favicons */} + + + + + + +) + +export default MetaTags diff --git a/apps/web/src/components/common/ModalDialog/index.tsx b/apps/web/src/components/common/ModalDialog/index.tsx new file mode 100644 index 000000000..6caeadea2 --- /dev/null +++ b/apps/web/src/components/common/ModalDialog/index.tsx @@ -0,0 +1,91 @@ +import { type ReactElement, type ReactNode } from 'react' +import { IconButton, type ModalProps } from '@mui/material' +import { Dialog, DialogTitle, type DialogProps, useMediaQuery } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import ChainIndicator from '@/components/common/ChainIndicator' +import CloseIcon from '@mui/icons-material/Close' + +import css from './styles.module.css' + +interface ModalDialogProps extends DialogProps { + dialogTitle?: React.ReactNode + hideChainIndicator?: boolean + chainId?: string +} + +interface DialogTitleProps { + children: ReactNode + onClose?: ModalProps['onClose'] + hideChainIndicator?: boolean + chainId?: string +} + +export const ModalDialogTitle = ({ + children, + onClose, + hideChainIndicator = false, + chainId, + ...other +}: DialogTitleProps) => { + return ( + + {children} + + {!hideChainIndicator && } + {onClose ? ( + { + onClose(e, 'backdropClick') + }} + size="small" + sx={{ + ml: 2, + color: 'border.main', + }} + > + + + ) : null} + + ) +} + +const ModalDialog = ({ + dialogTitle, + hideChainIndicator, + children, + fullScreen = false, + chainId, + ...restProps +}: ModalDialogProps): ReactElement => { + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')) + const isFullScreen = fullScreen || isSmallScreen + + return ( + e.stopPropagation()} + > + {dialogTitle && ( + + {dialogTitle} + + )} + + {children} + + ) +} + +export default ModalDialog diff --git a/apps/web/src/components/common/ModalDialog/styles.module.css b/apps/web/src/components/common/ModalDialog/styles.module.css new file mode 100644 index 000000000..2b230f6bf --- /dev/null +++ b/apps/web/src/components/common/ModalDialog/styles.module.css @@ -0,0 +1,25 @@ +.dialog :global .MuiDialogActions-root { + border-top: 1px solid var(--color-border-light); + padding: var(--space-2) var(--space-3); +} + +.dialog :global .MuiDialogActions-root > :last-of-type:not(:first-of-type) { + order: 2; +} + +.dialog :global .MuiDialogActions-root:after { + content: ''; + order: 1; + flex: 1; +} + +.dialog :global .MuiDialogTitle-root { + border-bottom: 1px solid var(--color-border-light); +} + +@media (min-width: 600px) { + .dialog :global .MuiDialog-paper { + min-width: 600px; + margin: 0; + } +} diff --git a/apps/web/src/components/common/Mui/index.test.tsx b/apps/web/src/components/common/Mui/index.test.tsx new file mode 100644 index 000000000..73ab5b759 --- /dev/null +++ b/apps/web/src/components/common/Mui/index.test.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { render } from '@/tests/test-utils' +import { Box } from './index' + +jest.mock('@mui/material/index.js', () => ({})) + +describe('Box Component', () => { + it('renders without crashing', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it('applies margin and padding props correctly', () => { + const { getByTestId } = render() + const box = getByTestId('box') + expect(box).toHaveStyle('margin: 16px') + expect(box).toHaveStyle('padding: 24px') + }) + + it('applies flex props correctly', () => { + const { getByTestId } = render() + const box = getByTestId('box') + expect(box).toHaveStyle('display: flex') + expect(box).toHaveStyle('flex-direction: column') + }) + + it('applies text alignment props correctly', () => { + const { getByTestId } = render() + const box = getByTestId('box') + expect(box).toHaveStyle('text-align: center') + }) + + it('should pass the sx prop to the MuiBox component', () => { + const { getByTestId } = render() + const box = getByTestId('box') + expect(box).toHaveStyle('padding: 24px') + expect(box).toHaveStyle('font-size: 14px') + }) +}) diff --git a/apps/web/src/components/common/Mui/index.tsx b/apps/web/src/components/common/Mui/index.tsx new file mode 100644 index 000000000..0ca0798ec --- /dev/null +++ b/apps/web/src/components/common/Mui/index.tsx @@ -0,0 +1,195 @@ +import { memo } from 'react' +import { default as MuiBox, type BoxProps } from '@mui/material/Box' +import { default as MuiTypograpahy, type TypographyProps } from '@mui/material/Typography' +import omitBy from 'lodash/omitBy' +import isUndefined from 'lodash/isUndefined' + +export * from '@mui/material/index' + +export const Box = memo(function Box({ + m, + mt, + mr, + mb, + ml, + mx, + my, + p, + pt, + pr, + pb, + pl, + px, + py, + width, + height, + minWidth, + minHeight, + maxWidth, + maxHeight, + display, + flex, + flexWrap, + flexGrow, + flexShrink, + flexDirection, + alignItems, + justifyItems, + alignContent, + justifyContent, + gap, + color, + textAlign, + position, + overflow, + textOverflow, + border, + borderRadius, + borderBottom, + borderColor, + bgcolor, + gridArea, + lineHeight, + sx, + ...props +}: BoxProps['sx'] & BoxProps) { + return ( + + ) +}) + +export const Typography = memo(function Typography({ + m, + mt, + mr, + mb, + ml, + mx, + my, + p, + pt, + pr, + pb, + pl, + px, + py, + display, + flex, + flexWrap, + flexGrow, + flexShrink, + flexDirection, + alignItems, + justifyItems, + alignContent, + justifyContent, + gap, + color, + textAlign, + fontSize, + fontWeight, + fontStyle, + lineHeight, + letterSpacing, + whiteSpace, + width, + sx, + ...props +}: TypographyProps['sx'] & TypographyProps) { + return ( + + ) +}) diff --git a/apps/web/src/components/common/NameInput/index.tsx b/apps/web/src/components/common/NameInput/index.tsx new file mode 100644 index 000000000..be31c2ba9 --- /dev/null +++ b/apps/web/src/components/common/NameInput/index.tsx @@ -0,0 +1,34 @@ +import type { TextFieldProps } from '@mui/material' +import { TextField } from '@mui/material' +import get from 'lodash/get' +import { type FieldError, useFormContext } from 'react-hook-form' +import inputCss from '@/styles/inputs.module.css' + +const NameInput = ({ + name, + required = false, + ...props +}: Omit & { + name: string + required?: boolean +}) => { + const { register, formState } = useFormContext() || {} + // the name can be a path: e.g. "owner.3.name" + const fieldError = get(formState.errors, name) as FieldError | undefined + + return ( + {fieldError?.type === 'maxLength' ? 'Maximum 50 symbols' : fieldError?.message || props.label}} + error={Boolean(fieldError)} + fullWidth + required={required} + className={inputCss.input} + onKeyDown={(e) => e.stopPropagation()} + {...register(name, { maxLength: 50, required })} + /> + ) +} + +export default NameInput diff --git a/src/components/common/NamedAddressInfo/index.test.tsx b/apps/web/src/components/common/NamedAddressInfo/index.test.tsx similarity index 100% rename from src/components/common/NamedAddressInfo/index.test.tsx rename to apps/web/src/components/common/NamedAddressInfo/index.test.tsx diff --git a/apps/web/src/components/common/NamedAddressInfo/index.tsx b/apps/web/src/components/common/NamedAddressInfo/index.tsx new file mode 100644 index 000000000..575f8fa78 --- /dev/null +++ b/apps/web/src/components/common/NamedAddressInfo/index.tsx @@ -0,0 +1,20 @@ +import useAsync from '@/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' + +const NamedAddressInfo = ({ address, name, customAvatar, ...props }: EthHashInfoProps) => { + const chainId = useChainId() + const [contract] = useAsync( + () => (!name && !customAvatar ? getContract(chainId, address) : undefined), + [address, chainId, name, customAvatar], + ) + + const finalName = name || contract?.displayName || contract?.name + const finalAvatar = customAvatar || contract?.logoUri + + return +} + +export default NamedAddressInfo diff --git a/apps/web/src/components/common/NavTabs/index.tsx b/apps/web/src/components/common/NavTabs/index.tsx new file mode 100644 index 000000000..2d498ea7b --- /dev/null +++ b/apps/web/src/components/common/NavTabs/index.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import NextLink from 'next/link' +import { Tab, Tabs, Typography } from '@mui/material' +import { useRouter } from 'next/router' +import type { NavItem } from '@/components/sidebar/SidebarNavigation/config' +import css from './styles.module.css' + +const NavTabs = ({ tabs }: { tabs: NavItem[] }) => { + const router = useRouter() + const activeTab = Math.max(0, tabs.map((tab) => tab.href).indexOf(router.pathname)) + const query = router.query.safe ? { safe: router.query.safe } : undefined + + return ( + + {tabs.map((tab, idx) => ( + + {tab.label} + + } + /> + ))} + + ) +} + +export default NavTabs diff --git a/src/components/common/NavTabs/styles.module.css b/apps/web/src/components/common/NavTabs/styles.module.css similarity index 100% rename from src/components/common/NavTabs/styles.module.css rename to apps/web/src/components/common/NavTabs/styles.module.css diff --git a/apps/web/src/components/common/Navigate/index.test.tsx b/apps/web/src/components/common/Navigate/index.test.tsx new file mode 100644 index 000000000..c8b14db7c --- /dev/null +++ b/apps/web/src/components/common/Navigate/index.test.tsx @@ -0,0 +1,29 @@ +import { render } from '@testing-library/react' +import type { NextRouter } from 'next/router' + +import { Navigate } from '@/components/common/Navigate' + +const mockRouter = { + replace: jest.fn(), + push: jest.fn(), +} as jest.MockedObjectDeep + +describe('Navigate', () => { + beforeEach(() => { + jest.resetAllMocks() + + jest.spyOn(require('next/navigation'), 'useRouter').mockReturnValue(mockRouter) + }) + + it('should navigate to the specified route', () => { + render() + + expect(mockRouter.push).toHaveBeenCalledWith('/test') + }) + + it('should replace the current route', () => { + render() + + expect(mockRouter.replace).toHaveBeenCalledWith('/test') + }) +}) diff --git a/apps/web/src/components/common/Navigate/index.tsx b/apps/web/src/components/common/Navigate/index.tsx new file mode 100644 index 000000000..a5cf8f27e --- /dev/null +++ b/apps/web/src/components/common/Navigate/index.tsx @@ -0,0 +1,16 @@ +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' + +export function Navigate({ to, replace = false }: { to: string; replace?: boolean }): null { + const router = useRouter() + + useEffect(() => { + if (replace) { + router.replace(to) + } else { + router.push(to) + } + }, [replace, router, to]) + + return null +} diff --git a/apps/web/src/components/common/NetworkInput/index.tsx b/apps/web/src/components/common/NetworkInput/index.tsx new file mode 100644 index 000000000..2e070cfac --- /dev/null +++ b/apps/web/src/components/common/NetworkInput/index.tsx @@ -0,0 +1,93 @@ +import ChainIndicator from '@/components/common/ChainIndicator' +import { useDarkMode } from '@/hooks/useDarkMode' +import { useTheme } from '@mui/material/styles' +import { FormControl, InputLabel, ListSubheader, MenuItem, Select, Typography } from '@mui/material' +import partition from 'lodash/partition' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import css from './styles.module.css' +import { type ReactElement, useCallback, useMemo } from 'react' +import { Controller, useFormContext } from 'react-hook-form' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +const NetworkInput = ({ + name, + required = false, + chainConfigs, +}: { + name: string + required?: boolean + chainConfigs: (ChainInfo & { available: boolean })[] +}): ReactElement => { + const isDarkMode = useDarkMode() + const theme = useTheme() + const [testNets, prodNets] = useMemo(() => partition(chainConfigs, (config) => config.isTestnet), [chainConfigs]) + const { control } = useFormContext() || {} + + const renderMenuItem = useCallback( + (chainId: string, isDisabled: boolean) => { + const chain = chainConfigs.find((chain) => chain.chainId === chainId) + if (!chain) return null + return ( + + + {isDisabled && ( + + Not available + + )} + + ) + }, + [chainConfigs], + ) + + return ( + ( + + Network + + + )} + /> + ) +} + +export default NetworkInput diff --git a/apps/web/src/components/common/NetworkInput/styles.module.css b/apps/web/src/components/common/NetworkInput/styles.module.css new file mode 100644 index 000000000..d0c269ffb --- /dev/null +++ b/apps/web/src/components/common/NetworkInput/styles.module.css @@ -0,0 +1,62 @@ +.select { + height: 100%; +} + +.select:after, +.select:before { + display: none; +} + +.select *:focus-visible { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; +} + +.select :global .MuiSelect-select { + padding-right: 40px !important; + padding-left: 16px; + height: 100%; + display: flex; + align-items: center; +} + +.select :global .MuiSelect-icon { + margin-right: var(--space-2); +} + +.select :global .Mui-disabled { + pointer-events: none; +} + +.select :global .MuiMenuItem-root { + padding: 0; +} + +.listSubHeader { + text-transform: uppercase; + font-size: 11px; + font-weight: bold; + line-height: 32px; +} + +.newChip { + font-weight: bold; + letter-spacing: -0.1px; + margin-top: -18px; + margin-left: -14px; + transform: scale(0.7); +} + +.item { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.disabledChip { + background-color: var(--color-border-light); + border-radius: 4px; + color: var(--color-text-primary); + padding: 4px 8px; + margin-left: auto; +} diff --git a/apps/web/src/components/common/NetworkSelector/NetworkMultiSelector.tsx b/apps/web/src/components/common/NetworkSelector/NetworkMultiSelector.tsx new file mode 100644 index 000000000..f9605ab4b --- /dev/null +++ b/apps/web/src/components/common/NetworkSelector/NetworkMultiSelector.tsx @@ -0,0 +1,176 @@ +import useChains, { useCurrentChain } from '@/hooks/useChains' +import useSafeAddress from '@/hooks/useSafeAddress' +import { useCallback, useEffect, type ReactElement } from 'react' +import { Checkbox, Autocomplete, TextField, Chip, Box } from '@mui/material' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import ChainIndicator from '../ChainIndicator' +import css from './styles.module.css' +import { Controller, useFormContext, useWatch } from 'react-hook-form' +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 { hasMultiChainCreationFeatures } from '@/features/multichain/utils/utils' + +const NetworkMultiSelector = ({ + name, + isAdvancedFlow = false, +}: { + name: string + isAdvancedFlow?: boolean +}): ReactElement => { + const { configs } = useChains() + const router = useRouter() + const safeAddress = useSafeAddress() + const currentChain = useCurrentChain() + + const { + formState: { errors }, + control, + getValues, + setValue, + } = useFormContext() + + const selectedNetworks: ChainInfo[] = useWatch({ control, name: SetNameStepFields.networks }) + + const updateCurrentNetwork = useCallback( + (chains: ChainInfo[]) => { + if (chains.length !== 1) return + const shortName = chains[0].shortName + const networkLink = getNetworkLink(router, safeAddress, shortName) + router.replace(networkLink) + }, + [router, safeAddress], + ) + + const handleDelete = useCallback( + (deletedChainId: string) => { + const currentValues: ChainInfo[] = getValues(name) || [] + const updatedValues = currentValues.filter((chain) => chain.chainId !== deletedChainId) + updateCurrentNetwork(updatedValues) + setValue(name, updatedValues, { shouldValidate: true }) + }, + [getValues, name, setValue, updateCurrentNetwork], + ) + + const isOptionDisabled = useCallback( + (optionNetwork: ChainInfo) => { + // Initially all networks are always available + if (selectedNetworks.length === 0) { + return false + } + + const firstSelectedNetwork = selectedNetworks[0] + + // do not allow multi chain safes for advanced setup flow. + if (isAdvancedFlow) return optionNetwork.chainId != firstSelectedNetwork.chainId + + // Check required feature toggles + const optionIsSelectedNetwork = firstSelectedNetwork.chainId === optionNetwork.chainId + if (!hasMultiChainCreationFeatures(optionNetwork) || !hasMultiChainCreationFeatures(firstSelectedNetwork)) { + return !optionIsSelectedNetwork + } + + // Check if required deployments are available + const optionHasCanonicalSingletonDeployment = + hasCanonicalDeployment( + getSafeSingletonDeployments({ + network: optionNetwork.chainId, + version: getLatestSafeVersion(firstSelectedNetwork), + }), + optionNetwork.chainId, + ) && + hasCanonicalDeployment( + getSafeToL2SetupDeployments({ network: optionNetwork.chainId, version: '1.4.1' }), + optionNetwork.chainId, + ) + + const selectedHasCanonicalSingletonDeployment = + hasCanonicalDeployment( + getSafeSingletonDeployments({ + network: firstSelectedNetwork.chainId, + version: getLatestSafeVersion(firstSelectedNetwork), + }), + firstSelectedNetwork.chainId, + ) && + hasCanonicalDeployment( + getSafeToL2SetupDeployments({ network: firstSelectedNetwork.chainId, version: '1.4.1' }), + firstSelectedNetwork.chainId, + ) + + // Only 1.4.1 safes with canonical deployment addresses and SafeToL2Setup can be deployed as part of a multichain group + if (!selectedHasCanonicalSingletonDeployment) return !optionIsSelectedNetwork + return !optionHasCanonicalSingletonDeployment + }, + [isAdvancedFlow, selectedNetworks], + ) + + useEffect(() => { + if (selectedNetworks.length === 1 && selectedNetworks[0].chainId !== currentChain?.chainId) { + updateCurrentNetwork([selectedNetworks[0]]) + } + }, [selectedNetworks, currentChain, updateCurrentNetwork]) + + return ( + <> + ( + + selectedOptions.map((chain) => ( + } + label={chain.chainName} + onDelete={() => handleDelete(chain.chainId)} + className={css.multiChainChip} + > + )) + } + renderOption={(props, chain, { selected }) => { + const { key, ...rest } = props + + return ( + + + + + ) + }} + getOptionLabel={(option) => option.chainName} + getOptionDisabled={isOptionDisabled} + renderInput={(params) => ( + + )} + filterOptions={(options, { inputValue }) => + options.filter((option) => option.chainName.toLowerCase().includes(inputValue.toLowerCase())) + } + isOptionEqualToValue={(option, value) => option.chainId === value.chainId} + onChange={(_, data) => { + updateCurrentNetwork(data) + return field.onChange(data) + }} + /> + )} + rules={{ required: true }} + /> + + ) +} + +export default NetworkMultiSelector diff --git a/apps/web/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx b/apps/web/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx new file mode 100644 index 000000000..23119e62a --- /dev/null +++ b/apps/web/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx @@ -0,0 +1,322 @@ +import * as useChains from '@/hooks/useChains' +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' + +const TestForm = ({ isAdvancedFlow = false }: { isAdvancedFlow?: boolean }) => { + const formMethods = useForm<{ networks: ChainInfo[] }>({ + mode: 'all', + defaultValues: { + networks: [], + }, + }) + + return ( + +
+ + +
+ ) +} + +describe('NetworkMultiSelector', () => { + const mockChains = [ + chainBuilder() + .with({ chainId: '1' }) + .with({ chainName: 'Ethereum' }) + .with({ shortName: 'eth' }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) + .build(), + chainBuilder() + .with({ chainId: '10' }) + .with({ chainName: 'Optimism' }) + .with({ shortName: 'oeth' }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) + .build(), + chainBuilder() + .with({ chainId: '100' }) + .with({ chainName: 'Gnosis Chain' }) + .with({ shortName: 'gno' }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) + .build(), + chainBuilder() + .with({ chainId: '324' }) + .with({ chainName: 'ZkSync Era' }) + .with({ shortName: 'zksync' }) + .with({ features: [FEATURES.COUNTERFACTUAL] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) + .build(), + chainBuilder() + .with({ chainId: '480' }) + .with({ chainName: 'Worldchain' }) + .with({ shortName: 'wc' }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) + .build(), + ] + + it('should be possible to select and deselect networks', async () => { + jest.spyOn(useChains, 'useCurrentChain').mockReturnValue(mockChains[0]) + const { getByRole, queryByText, getByText, getByTestId, getAllByRole } = render(, { + initialReduxState: { + chains: { + data: mockChains, + loading: false, + }, + }, + }) + const input = getByRole('combobox') + + act(() => { + userEvent.click(input) + }) + + // All options are visible and enabled initially + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + allOptions.forEach((option) => expect(option).toHaveAttribute('aria-disabled', 'false')) + expect(queryByText('Ethereum')).toBeVisible() + expect(queryByText('Optimism')).toBeVisible() + expect(queryByText('Gnosis Chain')).toBeVisible() + expect(queryByText('ZkSync Era')).toBeVisible() + expect(queryByText('Worldchain')).toBeVisible() + }) + + // Select Ethereum => zkSync Era should be disabled + act(() => { + userEvent.click(getByText('Ethereum')) + }) + + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + expect(allOptions[0]).toHaveAttribute('aria-disabled', 'false') + expect(allOptions[1]).toHaveAttribute('aria-disabled', 'false') + expect(allOptions[2]).toHaveAttribute('aria-disabled', 'false') + // ZkSync era is now disabled + expect(allOptions[3]).toHaveAttribute('aria-disabled', 'true') + expect(allOptions[3]).toHaveTextContent('ZkSync Era') + expect(allOptions[4]).toHaveAttribute('aria-disabled', 'false') + }) + + // Unselect Ethereum by clicking the x icon => zkSync Era should be enabled again + act(() => { + userEvent.click(getByTestId('CancelIcon')) + }) + + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + allOptions.forEach((option) => expect(option).toHaveAttribute('aria-disabled', 'false')) + }) + + // Select Multiple + act(() => { + const allOptions = getAllByRole('option') + userEvent.click(allOptions[0]) + userEvent.click(allOptions[1]) + userEvent.click(allOptions[2]) + }) + + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + expect(allOptions[0]).toHaveAttribute('aria-selected', 'true') + expect(allOptions[1]).toHaveAttribute('aria-selected', 'true') + expect(allOptions[2]).toHaveAttribute('aria-selected', 'true') + // ZkSync era is now disabled + expect(allOptions[3]).toHaveAttribute('aria-selected', 'false') + expect(allOptions[4]).toHaveAttribute('aria-selected', 'false') + }) + + // Close input + act(() => { + userEvent.click(input) + }) + + // Only the selected chains remain visible + await waitFor(() => { + expect(getByText('Ethereum')).toBeVisible() + expect(getByText('Optimism')).toBeVisible() + expect(getByText('Gnosis Chain')).toBeVisible() + expect(queryByText('Worldchain')).toBeNull() + }) + + // remove all + act(() => { + userEvent.click(getByTestId('CloseIcon')) + }) + + // No more chains are visible + await waitFor(() => { + expect(queryByText('Ethereum')).toBeNull() + expect(queryByText('Optimism')).toBeNull() + expect(queryByText('Gnosis Chain')).toBeNull() + expect(queryByText('Worldchain')).toBeNull() + expect(queryByText('Select at least one network')).toBeVisible() + }) + }) + + it('should disable all other chains when zkSync gets selected first', async () => { + jest.spyOn(useChains, 'useCurrentChain').mockReturnValue(mockChains[0]) + const { getByRole, queryByText, getByText, getAllByRole } = render(, { + initialReduxState: { + chains: { + data: mockChains, + loading: false, + }, + }, + }) + const input = getByRole('combobox') + + act(() => { + userEvent.click(input) + }) + + // All options are visible and enabled initially + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + allOptions.forEach((option) => expect(option).toHaveAttribute('aria-disabled', 'false')) + expect(queryByText('Ethereum')).toBeVisible() + expect(queryByText('Optimism')).toBeVisible() + expect(queryByText('Gnosis Chain')).toBeVisible() + expect(queryByText('ZkSync Era')).toBeVisible() + expect(queryByText('Worldchain')).toBeVisible() + }) + + // Select zkSync + act(() => { + userEvent.click(getByText('ZkSync Era')) + }) + + // All other networks get disabled + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + expect(allOptions[0]).toHaveAttribute('aria-disabled', 'true') + expect(allOptions[1]).toHaveAttribute('aria-disabled', 'true') + expect(allOptions[2]).toHaveAttribute('aria-disabled', 'true') + expect(allOptions[3]).toHaveAttribute('aria-disabled', 'false') + expect(allOptions[4]).toHaveAttribute('aria-disabled', 'true') + }) + }) + + it('should switch the router chain if a new network is selected', async () => { + const mockRouterReplace = jest.fn() + jest.spyOn(router, 'useRouter').mockReturnValue({ + replace: mockRouterReplace, + query: { chain: 'eth' }, + pathname: '/new-safe/create', + } as unknown as router.NextRouter) + + jest.spyOn(useChains, 'useCurrentChain').mockReturnValue(mockChains[0]) + const { getByRole, queryByText, getByText, getAllByRole } = render(, { + initialReduxState: { + chains: { + data: mockChains, + loading: false, + }, + }, + }) + const input = getByRole('combobox') + + act(() => { + userEvent.click(input) + }) + + // All options are visible and enabled initially + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + allOptions.forEach((option) => expect(option).toHaveAttribute('aria-disabled', 'false')) + expect(queryByText('Ethereum')).toBeVisible() + expect(queryByText('Optimism')).toBeVisible() + expect(queryByText('Gnosis Chain')).toBeVisible() + expect(queryByText('ZkSync Era')).toBeVisible() + expect(queryByText('Worldchain')).toBeVisible() + }) + + // Select first Optimism and Gnosis Chain + act(() => { + userEvent.click(getByText('Optimism')) + userEvent.click(getByText('Gnosis Chain')) + }) + + // As we were connected to Ethereum and had only one selected network different from Ethereum, we should switch the chain to Optimism + await waitFor(() => { + expect(mockRouterReplace).toHaveBeenCalledWith({ + pathname: '/new-safe/create', + query: { + chain: 'oeth', + }, + }) + }) + }) + + it('should only allow single chain selection if advanced flow', async () => { + jest.spyOn(useChains, 'useCurrentChain').mockReturnValue(mockChains[0]) + const { getByRole, queryByText, getByText, getByTestId, getAllByRole } = render(, { + initialReduxState: { + chains: { + data: mockChains, + loading: false, + }, + }, + }) + const input = getByRole('combobox') + + act(() => { + userEvent.click(input) + }) + + // All options are visible and enabled initially + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + allOptions.forEach((option) => expect(option).toHaveAttribute('aria-disabled', 'false')) + expect(queryByText('Ethereum')).toBeVisible() + expect(queryByText('Optimism')).toBeVisible() + expect(queryByText('Gnosis Chain')).toBeVisible() + expect(queryByText('ZkSync Era')).toBeVisible() + expect(queryByText('Worldchain')).toBeVisible() + }) + + // Select Ethereum => all other options get disabled + act(() => { + userEvent.click(getByText('Ethereum')) + }) + + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + expect(allOptions[0]).toHaveAttribute('aria-disabled', 'false') + expect(allOptions[1]).toHaveAttribute('aria-disabled', 'true') + expect(allOptions[2]).toHaveAttribute('aria-disabled', 'true') + expect(allOptions[3]).toHaveAttribute('aria-disabled', 'true') + expect(allOptions[4]).toHaveAttribute('aria-disabled', 'true') + }) + + // Unselect Ethereum by clicking the x icon => all are enabled again + act(() => { + userEvent.click(getByTestId('CancelIcon')) + }) + + await waitFor(() => { + const allOptions = getAllByRole('option') + expect(allOptions).toHaveLength(5) + allOptions.forEach((option) => expect(option).toHaveAttribute('aria-disabled', 'false')) + }) + }) +}) diff --git a/apps/web/src/components/common/NetworkSelector/index.tsx b/apps/web/src/components/common/NetworkSelector/index.tsx new file mode 100644 index 000000000..4f494817b --- /dev/null +++ b/apps/web/src/components/common/NetworkSelector/index.tsx @@ -0,0 +1,469 @@ +import ChainIndicator from '@/components/common/ChainIndicator' +import Track from '@/components/common/Track' +import { useDarkMode } from '@/hooks/useDarkMode' +import { useAppSelector } from '@/store' +import { selectChains } from '@/store/chainsSlice' +import { useTheme } from '@mui/material/styles' +import Link from 'next/link' +import { + Box, + ButtonBase, + CircularProgress, + Collapse, + Divider, + MenuItem, + Select, + Skeleton, + Stack, + Tooltip, + Typography, +} from '@mui/material' +import partition from 'lodash/partition' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +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 uniq from 'lodash/uniq' +import { useCompatibleNetworks } from '@/features/multichain/hooks/useCompatibleNetworks' +import { useSafeCreationData } from '@/features/multichain/hooks/useSafeCreationData' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +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 { 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 = ({ + isSelected, + chain, + safeAddress, +}: { + isSelected: boolean + chain: ChainInfo + safeAddress: string +}) => { + const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chain.chainId, safeAddress)) + const { data: safeOverview } = useGetSafeOverviewQuery( + undeployedSafe ? skipToken : { safeAddress, chainId: chain.chainId }, + ) + + return ( + + ) +} + +export const getNetworkLink = (router: NextRouter, safeAddress: string, networkShortName: string) => { + const isSafeOpened = safeAddress !== '' + + const query = ( + isSafeOpened + ? { + safe: `${networkShortName}:${safeAddress}`, + } + : { chain: networkShortName } + ) as { + safe?: string + chain?: string + safeViewRedirectURL?: string + } + + const route = { + pathname: router.pathname, + query, + } + + if (router.query?.safeViewRedirectURL) { + route.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() + } + + return route +} + +const UndeployedNetworkMenuItem = ({ + chain, + isSelected = false, + onSelect, +}: { + chain: ChainInfo & { available: boolean } + isSelected?: boolean + onSelect: (chain: ChainInfo) => void +}) => { + const isDisabled = !chain.available + + return ( + + + onSelect(chain)} + disabled={isDisabled} + > + + + {isDisabled ? ( + + Not available + + ) : ( + + )} + + + + + ) +} + +const NetworkSkeleton = () => { + return ( + + + + + ) +} + +const TestnetDivider = () => { + return ( + + + Testnets + + + ) +} + +const UndeployedNetworks = ({ + deployedChains, + chains, + safeAddress, + closeNetworkSelect, +}: { + deployedChains: string[] + chains: ChainInfo[] + safeAddress: string + closeNetworkSelect: () => void +}) => { + const [open, setOpen] = useState(false) + const [replayOnChain, setReplayOnChain] = useState() + const addressBook = useAddressBook() + const safeName = addressBook[safeAddress] + const deployedChainInfos = useMemo( + () => chains.filter((chain) => deployedChains.includes(chain.chainId)), + [chains, deployedChains], + ) + const safeCreationResult = useSafeCreationData(safeAddress, deployedChainInfos) + const [safeCreationData, safeCreationDataError, safeCreationLoading] = safeCreationResult + + const allCompatibleChains = useCompatibleNetworks(safeCreationData) + const isUnsupportedSafeCreationVersion = Boolean(!allCompatibleChains?.length) + + const availableNetworks = useMemo( + () => + allCompatibleChains?.filter( + (config) => !deployedChains.includes(config.chainId) && hasMultiChainAddNetworkFeature(config), + ) || [], + [allCompatibleChains, deployedChains], + ) + + const [testNets, prodNets] = useMemo( + () => partition(availableNetworks, (config) => config.isTestnet), + [availableNetworks], + ) + + const noAvailableNetworks = useMemo(() => availableNetworks.every((config) => !config.available), [availableNetworks]) + + const onSelect = (chain: ChainInfo) => { + setReplayOnChain(chain) + } + + if (safeCreationLoading) { + return ( + + + + ) + } + + const errorMessage = + safeCreationDataError || (safeCreationData && noAvailableNetworks) ? ( + + {safeCreationDataError?.message && ( + + + + )} + Adding another network is not possible for this Safe. + + ) : isUnsupportedSafeCreationVersion ? ( + 'This account was created from an outdated mastercopy. Adding another network is not possible.' + ) : ( + '' + ) + + if (errorMessage) { + return ( + + + {errorMessage} + + + ) + } + + const onFormClose = () => { + setReplayOnChain(undefined) + closeNetworkSelect() + } + + const onShowAllNetworks = () => { + !open && trackEvent(OVERVIEW_EVENTS.SHOW_ALL_NETWORKS) + setOpen((prev) => !prev) + } + + return ( + <> + + +
Show all networks
+ + +
+
+ + {!safeCreationData ? ( + + + + + ) : ( + <> + {prodNets.map((chain) => ( + + ))} + {testNets.length > 0 && } + {testNets.map((chain) => ( + + ))} + + )} + + {replayOnChain && safeCreationData && ( + + )} + + ) +} + +const NetworkSelector = ({ + onChainSelect, + offerSafeCreation = false, +}: { + onChainSelect?: () => void + offerSafeCreation?: boolean +}): ReactElement => { + const [open, setOpen] = useState(false) + const isDarkMode = useDarkMode() + const theme = useTheme() + const { configs } = useChains() + const chainId = useChainId() + const router = useRouter() + const safeAddress = useSafeAddress() + const currentChain = useCurrentChain() + const chains = useAppSelector(selectChains) + + const isSafeOpened = safeAddress !== '' + + const addNetworkFeatureEnabled = hasMultiChainAddNetworkFeature(currentChain) + + const safesGrouped = useAllSafesGrouped() + const availableChainIds = useMemo(() => { + if (!isSafeOpened) { + // Offer all chains + return configs.map((config) => config.chainId) + } + return uniq([ + chainId, + ...(safesGrouped.allMultiChainSafes + ?.find((item) => sameAddress(item.address, safeAddress)) + ?.safes.map((safe) => safe.chainId) ?? []), + ]) + }, [chainId, configs, isSafeOpened, safeAddress, safesGrouped.allMultiChainSafes]) + + const [testNets, prodNets] = useMemo( + () => + partition( + configs.filter((config) => availableChainIds.includes(config.chainId)), + (config) => config.isTestnet, + ), + [availableChainIds, configs], + ) + + const renderMenuItem = useCallback( + (chainId: string, isSelected: boolean) => { + const chain = chains.data.find((chain) => chain.chainId === chainId) + if (!chain) return null + + const onSwitchNetwork = () => { + trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: chainId }) + } + + return ( + + + + + + ) + }, + [chains.data, onChainSelect, router, safeAddress], + ) + + const handleClose = () => { + setOpen(false) + } + + const handleOpen = () => { + setOpen(true) + offerSafeCreation && trackEvent({ ...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE, label: OVERVIEW_LABELS.top_bar }) + } + + return configs.length ? ( + + ) : ( + + ) +} + +export default NetworkSelector diff --git a/apps/web/src/components/common/NetworkSelector/styles.module.css b/apps/web/src/components/common/NetworkSelector/styles.module.css new file mode 100644 index 000000000..f53b932c4 --- /dev/null +++ b/apps/web/src/components/common/NetworkSelector/styles.module.css @@ -0,0 +1,88 @@ +.select { + height: 100%; +} + +.select:after, +.select:before { + display: none; +} + +.select *:focus-visible { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; +} + +.select :global .MuiSelect-select { + padding-right: 40px !important; + padding-left: 16px; + height: 100%; + display: flex; + align-items: center; +} + +.select :global .MuiSelect-icon { + margin-right: var(--space-2); +} + +.select :global .Mui-disabled { + pointer-events: none; +} + +.select :global .MuiMenuItem-root { + padding: 0; +} + +.listSubHeader { + background-color: var(--color-background-main); + text-transform: uppercase; + font-size: 11px; + font-weight: bold; + line-height: 32px; + text-align: center; + letter-spacing: 1px; + width: 100%; + margin-top: var(--space-1); +} + +[data-theme='dark'] .undeployedNetworksHeader { + background-color: var(--color-secondary-background); +} + +.plusIcon { + background-color: var(--color-background-main); + color: var(--color-border-main); + border-radius: 100%; + height: 20px; + width: 20px; + padding: 4px; + margin-left: auto; +} + +.newChip { + font-weight: bold; + letter-spacing: -0.1px; + margin-top: -18px; + margin-left: -14px; + transform: scale(0.7); +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-1); + width: 100%; +} + +.multiChainChip { + padding: var(--space-2) 0; + margin: 2px; + border-color: var(--color-border-main); +} + +.comingSoon { + background-color: var(--color-border-light); + border-radius: 4px; + color: var(--color-text-primary); + padding: 4px 8px; +} diff --git a/apps/web/src/components/common/NetworkSelector/useChangeNetworkLink.ts b/apps/web/src/components/common/NetworkSelector/useChangeNetworkLink.ts new file mode 100644 index 000000000..be1a7368e --- /dev/null +++ b/apps/web/src/components/common/NetworkSelector/useChangeNetworkLink.ts @@ -0,0 +1,27 @@ +import { AppRoutes } from '@/config/routes' +import useWallet from '@/hooks/wallets/useWallet' +import { useRouter } from 'next/router' + +export const useChangeNetworkLink = (networkShortName: string) => { + const router = useRouter() + const isWalletConnected = !!useWallet() + const pathname = router.pathname + + const shouldKeepPath = !router.query.safe + + const route = { + pathname: shouldKeepPath ? pathname : isWalletConnected ? AppRoutes.welcome.accounts : AppRoutes.welcome.index, + query: { + chain: networkShortName, + } as { + chain: string + safeViewRedirectURL?: string + }, + } + + if (router.query?.safeViewRedirectURL) { + route.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() + } + + return route +} diff --git a/apps/web/src/components/common/Notifications/index.tsx b/apps/web/src/components/common/Notifications/index.tsx new file mode 100644 index 000000000..bc2940102 --- /dev/null +++ b/apps/web/src/components/common/Notifications/index.tsx @@ -0,0 +1,161 @@ +import type { ReactElement, SyntheticEvent } from 'react' +import React, { useCallback, useEffect } from 'react' +import groupBy from 'lodash/groupBy' +import { useAppDispatch, useAppSelector } from '@/store' +import type { Notification } from '@/store/notificationsSlice' +import { closeNotification, readNotification, selectNotifications } from '@/store/notificationsSlice' +import type { AlertColor, SnackbarCloseReason } from '@mui/material' +import { Alert, Box, Link, Snackbar, Typography } from '@mui/material' +import css from './styles.module.css' +import NextLink from 'next/link' +import ChevronRightIcon from '@mui/icons-material/ChevronRight' +import { OVERVIEW_EVENTS } from '@/services/analytics/events/overview' +import Track from '../Track' +import { isRelativeUrl } from '@/utils/url' + +const toastStyle = { position: 'static', margin: 1 } + +export const NotificationLink = ({ + link, + onClick, +}: { + link: Notification['link'] + onClick: (_: Event | SyntheticEvent) => void +}): ReactElement | null => { + if (!link) { + return null + } + + const LinkWrapper = ({ children }: React.PropsWithChildren) => + 'href' in link ? ( + + {children} + + ) : ( + {children} + ) + + const handleClick = (event: SyntheticEvent) => { + if ('onClick' in link) { + link.onClick() + } + onClick(event) + } + + const isExternal = + 'href' in link && + (typeof link.href === 'string' ? !isRelativeUrl(link.href) : !!(link.href.host || link.href.hostname)) + + return ( + + + + {link.title} + + + + + ) +} + +const Toast = ({ + title, + message, + detailedMessage, + variant, + link, + onClose, + id, +}: { + variant: AlertColor + onClose: () => void +} & Notification) => { + const dispatch = useAppDispatch() + + const handleClose = (_: Event | SyntheticEvent, reason?: SnackbarCloseReason) => { + if (reason === 'clickaway') return + + // Manually closed + if (!reason) { + dispatch(readNotification({ id })) + } + + onClose() + } + + const autoHideDuration = variant === 'info' || variant === 'success' ? 5000 : undefined + + return ( + + + {title && ( + + {title} + + )} + + {message} + + {detailedMessage && ( +
+ Details +
{detailedMessage}
+
+ )} + +
+
+ ) +} + +const getVisibleNotifications = (notifications: Notification[]) => { + return notifications.filter((notification) => !notification.isDismissed) +} + +const Notifications = (): ReactElement | null => { + const notifications = useAppSelector(selectNotifications) + const dispatch = useAppDispatch() + + const visible = getVisibleNotifications(notifications) + + const visibleItems = visible.length + + const handleClose = useCallback( + (item: Notification) => { + dispatch(closeNotification(item)) + item.onClose?.() + }, + [dispatch], + ) + + // Close previous notifications in the same group + useEffect(() => { + const groups: Record = groupBy(notifications, 'groupKey') + + Object.values(groups).forEach((items) => { + const previous = getVisibleNotifications(items).slice(0, -1) + previous.forEach(handleClose) + }) + }, [notifications, handleClose]) + + if (visibleItems === 0) { + return null + } + + return ( +
+ {visible.map((item) => ( +
+ handleClose(item)} /> +
+ ))} +
+ ) +} + +export default Notifications diff --git a/src/components/common/Notifications/styles.module.css b/apps/web/src/components/common/Notifications/styles.module.css similarity index 100% rename from src/components/common/Notifications/styles.module.css rename to apps/web/src/components/common/Notifications/styles.module.css diff --git a/src/components/common/Notifications/useCounter.ts b/apps/web/src/components/common/Notifications/useCounter.ts similarity index 100% rename from src/components/common/Notifications/useCounter.ts rename to apps/web/src/components/common/Notifications/useCounter.ts diff --git a/src/components/common/NumberField/index.test.ts b/apps/web/src/components/common/NumberField/index.test.ts similarity index 100% rename from src/components/common/NumberField/index.test.ts rename to apps/web/src/components/common/NumberField/index.test.ts diff --git a/src/components/common/NumberField/index.tsx b/apps/web/src/components/common/NumberField/index.tsx similarity index 100% rename from src/components/common/NumberField/index.tsx rename to apps/web/src/components/common/NumberField/index.tsx diff --git a/src/components/common/OnboardingTooltip/__tests__/OnboardingTooltip.test.tsx b/apps/web/src/components/common/OnboardingTooltip/__tests__/OnboardingTooltip.test.tsx similarity index 100% rename from src/components/common/OnboardingTooltip/__tests__/OnboardingTooltip.test.tsx rename to apps/web/src/components/common/OnboardingTooltip/__tests__/OnboardingTooltip.test.tsx diff --git a/apps/web/src/components/common/OnboardingTooltip/index.tsx b/apps/web/src/components/common/OnboardingTooltip/index.tsx new file mode 100644 index 000000000..459ff786a --- /dev/null +++ b/apps/web/src/components/common/OnboardingTooltip/index.tsx @@ -0,0 +1,60 @@ +import type { ReactElement } from 'react' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import type { TooltipProps } from '@mui/material' +import { Box, Button, SvgIcon, Tooltip } from '@mui/material' +import InfoIcon from '@/public/images/notifications/info.svg' +import { useDarkMode } from '@/hooks/useDarkMode' + +/** + * The OnboardingTooltip renders a sticky Tooltip with an arrow pointing towards the wrapped component. + * This Tooltip contains a button to hide it. This decision will be stored in the local storage such that the OnboardingTooltip will only popup until clicked away once. + */ +export const OnboardingTooltip = ({ + children, + widgetLocalStorageId, + text, + initiallyShown = true, + className, + placement, +}: { + children: ReactElement // NB: this has to be an actual HTML element, otherwise the Tooltip will not work + widgetLocalStorageId: string + text: string | ReactElement + initiallyShown?: boolean + className?: string + placement?: TooltipProps['placement'] +}): ReactElement => { + const [widgetHidden = !initiallyShown, setWidgetHidden] = useLocalStorage(widgetLocalStorageId) + const isDarkMode = useDarkMode() + + return widgetHidden || !text ? ( + children + ) : ( + + +
{text}
+ +
+ } + > + {children} + + ) +} diff --git a/apps/web/src/components/common/OnlyOwner/index.tsx b/apps/web/src/components/common/OnlyOwner/index.tsx new file mode 100644 index 000000000..fdc122688 --- /dev/null +++ b/apps/web/src/components/common/OnlyOwner/index.tsx @@ -0,0 +1,40 @@ +import { useMemo, type ReactElement } from 'react' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import useWallet from '@/hooks/wallets/useWallet' +import useConnectWallet from '../ConnectWallet/useConnectWallet' +import { Tooltip } from '@mui/material' + +type CheckWalletProps = { + children: (ok: boolean) => ReactElement +} + +enum Message { + WalletNotConnected = 'Please connect your wallet', + NotSafeOwner = 'Your connected wallet is not a signer of this Safe Account', +} + +const OnlyOwner = ({ children }: CheckWalletProps): ReactElement => { + const wallet = useWallet() + const isSafeOwner = useIsSafeOwner() + const connectWallet = useConnectWallet() + + const message = useMemo(() => { + if (!wallet) { + return Message.WalletNotConnected + } + + if (!isSafeOwner) { + return Message.NotSafeOwner + } + }, [isSafeOwner, wallet]) + + if (!message) return children(true) + + return ( + + {children(false)} + + ) +} + +export default OnlyOwner diff --git a/src/components/common/PageHeader/index.tsx b/apps/web/src/components/common/PageHeader/index.tsx similarity index 100% rename from src/components/common/PageHeader/index.tsx rename to apps/web/src/components/common/PageHeader/index.tsx diff --git a/src/components/common/PageHeader/styles.module.css b/apps/web/src/components/common/PageHeader/styles.module.css similarity index 100% rename from src/components/common/PageHeader/styles.module.css rename to apps/web/src/components/common/PageHeader/styles.module.css diff --git a/src/components/common/PageLayout/SideDrawer.tsx b/apps/web/src/components/common/PageLayout/SideDrawer.tsx similarity index 92% rename from src/components/common/PageLayout/SideDrawer.tsx rename to apps/web/src/components/common/PageLayout/SideDrawer.tsx index b96686f19..77eeda7b7 100644 --- a/src/components/common/PageLayout/SideDrawer.tsx +++ b/apps/web/src/components/common/PageLayout/SideDrawer.tsx @@ -48,6 +48,11 @@ const SideDrawer = ({ isOpen, onToggle }: SideDrawerProps): ReactElement => { anchor="left" open={isOpen} onClose={() => onToggle(false)} + sx={{ + // fixes a bug on small screens where the drawer is not visible, + // but it steals all the events from the rest of the page + position: 'relative', + }} className={smDrawerHidden ? css.smDrawerHidden : undefined} >
}> + + + + + ) +} diff --git a/src/components/safe-messages/DecodedMsg/styles.module.css b/apps/web/src/components/safe-messages/DecodedMsg/styles.module.css similarity index 100% rename from src/components/safe-messages/DecodedMsg/styles.module.css rename to apps/web/src/components/safe-messages/DecodedMsg/styles.module.css diff --git a/src/components/safe-messages/InfoBox/index.tsx b/apps/web/src/components/safe-messages/InfoBox/index.tsx similarity index 100% rename from src/components/safe-messages/InfoBox/index.tsx rename to apps/web/src/components/safe-messages/InfoBox/index.tsx diff --git a/src/components/safe-messages/InfoBox/styles.module.css b/apps/web/src/components/safe-messages/InfoBox/styles.module.css similarity index 100% rename from src/components/safe-messages/InfoBox/styles.module.css rename to apps/web/src/components/safe-messages/InfoBox/styles.module.css diff --git a/src/components/safe-messages/Msg/index.tsx b/apps/web/src/components/safe-messages/Msg/index.tsx similarity index 100% rename from src/components/safe-messages/Msg/index.tsx rename to apps/web/src/components/safe-messages/Msg/index.tsx diff --git a/src/components/safe-messages/Msg/styles.module.css b/apps/web/src/components/safe-messages/Msg/styles.module.css similarity index 100% rename from src/components/safe-messages/Msg/styles.module.css rename to apps/web/src/components/safe-messages/Msg/styles.module.css diff --git a/apps/web/src/components/safe-messages/MsgDetails/index.tsx b/apps/web/src/components/safe-messages/MsgDetails/index.tsx new file mode 100644 index 000000000..db757ba5b --- /dev/null +++ b/apps/web/src/components/safe-messages/MsgDetails/index.tsx @@ -0,0 +1,137 @@ +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 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 txDetailsCss from '@/components/transactions/TxDetails/styles.module.css' +import singleTxDecodedCss from '@/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/styles.module.css' +import infoDetailsCss from '@/components/transactions/InfoDetails/styles.module.css' +import { DecodedMsg } from '../DecodedMsg' +import CopyButton from '@/components/common/CopyButton' +import NamedAddressInfo from '@/components/common/NamedAddressInfo' +import MsgShareLink from '../MsgShareLink' + +const MsgDetails = ({ msg }: { msg: SafeMessage }): ReactElement => { + const wallet = useWallet() + const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED + const safeMessage = useMemo(() => { + try { + return generateSafeMessageMessage(msg.message) + } catch (e) { + return '' + } + }, [msg.message]) + const verifyingContract = isEIP712TypedData(msg.message) ? msg.message.domain.verifyingContract : undefined + + return ( +
+
+
+ +
+
+ + + +
+ + {verifyingContract && ( +
+ + + +
+ )} + +
+ + Message + + } + > + Error decoding message
}> + + + +
+ +
+ {formatDateTime(msg.creationTimestamp)} + {formatDateTime(msg.modifiedTimestamp)} + {generateDataRowValue(msg.messageHash, 'hash')} + {safeMessage && {generateDataRowValue(safeMessage, 'hash')}} +
+ + {msg.preparedSignature && ( +
+ {generateDataRowValue(msg.preparedSignature, 'hash')} +
+ )} + +
+ {msg.confirmations.map((confirmation, i) => ( + + }> +
+ + {`Confirmation ${i + 1}`} +
+
+ + +
+ +
+ + + +
+
+ ))} +
+
+
+ + {wallet && !isConfirmed && ( + + + + )} +
+
+ ) +} + +export default MsgDetails diff --git a/src/components/safe-messages/MsgList/index.tsx b/apps/web/src/components/safe-messages/MsgList/index.tsx similarity index 100% rename from src/components/safe-messages/MsgList/index.tsx rename to apps/web/src/components/safe-messages/MsgList/index.tsx diff --git a/src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx b/apps/web/src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx similarity index 79% rename from src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx rename to apps/web/src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx index 22e5637f0..8e73fa890 100644 --- a/src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx +++ b/apps/web/src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx @@ -1,7 +1,8 @@ -import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material' +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' import MsgSummary from '@/components/safe-messages/MsgSummary' @@ -26,7 +27,9 @@ const ExpandableMsgItem = ({ msg, expanded = false }: { msg: SafeMessage; expand - + Failed to render message details}> + + ) diff --git a/src/components/safe-messages/MsgListItem/index.tsx b/apps/web/src/components/safe-messages/MsgListItem/index.tsx similarity index 100% rename from src/components/safe-messages/MsgListItem/index.tsx rename to apps/web/src/components/safe-messages/MsgListItem/index.tsx diff --git a/src/components/safe-messages/MsgShareLink/index.tsx b/apps/web/src/components/safe-messages/MsgShareLink/index.tsx similarity index 100% rename from src/components/safe-messages/MsgShareLink/index.tsx rename to apps/web/src/components/safe-messages/MsgShareLink/index.tsx diff --git a/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx b/apps/web/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx similarity index 100% rename from src/components/safe-messages/MsgSigners/MsgSigners.test.tsx rename to apps/web/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx diff --git a/apps/web/src/components/safe-messages/MsgSigners/index.tsx b/apps/web/src/components/safe-messages/MsgSigners/index.tsx new file mode 100644 index 000000000..96f64fd05 --- /dev/null +++ b/apps/web/src/components/safe-messages/MsgSigners/index.tsx @@ -0,0 +1,161 @@ +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' +import SignedIcon from '@/public/images/messages/signed.svg' +import DotIcon from '@/public/images/messages/dot.svg' +import EthHashInfo from '@/components/common/EthHashInfo' + +import css from '@/components/safe-messages/MsgSigners/styles.module.css' +import txSignersCss from '@/components/transactions/TxSigners/styles.module.css' + +// Icons + +const Created = () => ( + palette.background.paper }, + }} + /> +) + +const Signed = () => ( + palette.background.paper }, + }} + /> +) + +const Dot = () => + +const shouldHideConfirmations = (msg: SafeMessage): boolean => { + const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED + + // Threshold reached or more than 3 confirmations + return isConfirmed || msg.confirmations.length > 3 +} + +export const MsgSigners = ({ + msg, + showOnlyConfirmations = false, + showMissingSignatures = false, + backgroundColor, +}: { + msg: SafeMessage + showOnlyConfirmations?: boolean + showMissingSignatures?: boolean + backgroundColor?: string +}): ReactElement => { + const [hideConfirmations, setHideConfirmations] = useState(shouldHideConfirmations(msg)) + + const toggleHide = () => { + setHideConfirmations((prev) => !prev) + } + + const { confirmations, confirmationsRequired, confirmationsSubmitted } = msg + + const missingConfirmations = [...new Array(Math.max(0, confirmationsRequired - confirmationsSubmitted))] + + const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED + + return ( + + {!showOnlyConfirmations && ( + + + + + Created + + )} + + + + + + Confirmations{' '} + + ({`${confirmationsSubmitted} of ${confirmationsRequired}`}) + + + + {!hideConfirmations && + confirmations.map(({ owner }) => ( + + + + + + + + + ))} + {!showOnlyConfirmations && confirmations.length > 0 && ( + + + + + + + {hideConfirmations ? 'Show all' : 'Hide all'} + + + + )} + {showMissingSignatures && + missingConfirmations.map((_, idx) => ( + + + + + + + + + Confirmation #{idx + 1 + confirmationsSubmitted} + + + + + ))} + {isConfirmed && ( + + + + + Confirmed + + )} + + ) +} + +export default MsgSigners diff --git a/src/components/safe-messages/MsgSigners/styles.module.css b/apps/web/src/components/safe-messages/MsgSigners/styles.module.css similarity index 100% rename from src/components/safe-messages/MsgSigners/styles.module.css rename to apps/web/src/components/safe-messages/MsgSigners/styles.module.css diff --git a/apps/web/src/components/safe-messages/MsgSummary/index.tsx b/apps/web/src/components/safe-messages/MsgSummary/index.tsx new file mode 100644 index 000000000..a8e1057cf --- /dev/null +++ b/apps/web/src/components/safe-messages/MsgSummary/index.tsx @@ -0,0 +1,80 @@ +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' +import SignMsgButton from '@/components/safe-messages/SignMsgButton' +import useSafeMessageStatus from '@/hooks/messages/useSafeMessageStatus' +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' + +const getStatusColor = (value: SafeMessageStatus, palette: Palette): string => { + switch (value) { + case SafeMessageStatus.CONFIRMED: + return palette.success.main + case SafeMessageStatus.NEEDS_CONFIRMATION: + return palette.warning.main + default: + return palette.text.primary + } +} + +const MsgSummary = ({ msg }: { msg: SafeMessage }): ReactElement => { + const { confirmationsSubmitted, confirmationsRequired } = msg + const txStatusLabel = useSafeMessageStatus(msg) + const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED + const isPending = useIsSafeMessagePending(msg.messageHash) + let type = '' + if (isEIP712TypedData(msg.message)) { + type = (msg.message as unknown as { primaryType: string }).primaryType + } + + return ( + + + + + + {type || 'Signature'} + + + + + + + {confirmationsRequired > 0 && ( + + )} + + + + {isConfirmed || isPending ? ( + getStatusColor(msg.status, palette) }} + > + {isPending && } + + {txStatusLabel} + + ) : ( + + )} + + + ) +} + +export default MsgSummary diff --git a/src/components/safe-messages/MsgType/index.tsx b/apps/web/src/components/safe-messages/MsgType/index.tsx similarity index 100% rename from src/components/safe-messages/MsgType/index.tsx rename to apps/web/src/components/safe-messages/MsgType/index.tsx diff --git a/apps/web/src/components/safe-messages/PaginatedMsgs/index.tsx b/apps/web/src/components/safe-messages/PaginatedMsgs/index.tsx new file mode 100644 index 000000000..d140c9b87 --- /dev/null +++ b/apps/web/src/components/safe-messages/PaginatedMsgs/index.tsx @@ -0,0 +1,107 @@ +import { Box } from '@mui/material' +import { Typography, Link, SvgIcon } from '@mui/material' +import { useEffect, useState } from 'react' +import type { ReactElement } from 'react' + +import ErrorMessage from '@/components/tx/ErrorMessage' +import useSafeMessages from '@/hooks/messages/useSafeMessages' +import LinkIcon from '@/public/images/common/link.svg' +import NoMessagesIcon from '@/public/images/messages/no-messages.svg' +import InfiniteScroll from '@/components/common/InfiniteScroll' +import PagePlaceholder from '@/components/common/PagePlaceholder' +import MsgList from '@/components/safe-messages/MsgList' +import SkeletonTxList from '@/components/common/PaginatedTxns/SkeletonTxList' +import { HelpCenterArticle } from '@/config/constants' +import useSafeInfo from '@/hooks/useSafeInfo' + +const NoMessages = (): ReactElement => { + return ( + } + text={ + + Some applications allow you to interact with them via off-chain contract signatures (“messages“) + that you can generate with your Safe Account. + + } + > + + Learn more about off-chain messages{' '} + + + + ) +} + +const MsgPage = ({ + pageUrl, + onNextPage, +}: { + pageUrl: string + onNextPage?: (pageUrl: string) => void +}): ReactElement => { + const { page, error, loading } = useSafeMessages(pageUrl) + + return ( + <> + {page && page.results.length > 0 && } + {page?.results.length === 0 && } + {error && Error loading messages} + {loading && } + {page?.next && onNextPage && ( + + onNextPage(page.next!)} /> + + )} + + ) +} + +const PaginatedMsgs = (): ReactElement => { + const [pages, setPages] = useState(['']) + const { safeAddress, safe } = useSafeInfo() + + // Trigger the next page load + const onNextPage = (pageUrl: string) => { + setPages((prev) => prev.concat(pageUrl)) + } + + // Reset the pages when the Safe Account changes + useEffect(() => { + setPages(['']) + }, [safe.chainId, safeAddress]) + + return ( + + {pages.map((pageUrl, index) => ( + + ))} + + ) +} + +export default PaginatedMsgs diff --git a/src/components/safe-messages/SignMsgButton/index.tsx b/apps/web/src/components/safe-messages/SignMsgButton/index.tsx similarity index 100% rename from src/components/safe-messages/SignMsgButton/index.tsx rename to apps/web/src/components/safe-messages/SignMsgButton/index.tsx diff --git a/src/components/safe-messages/SingleMsg/SingleMsg.test.tsx b/apps/web/src/components/safe-messages/SingleMsg/SingleMsg.test.tsx similarity index 100% rename from src/components/safe-messages/SingleMsg/SingleMsg.test.tsx rename to apps/web/src/components/safe-messages/SingleMsg/SingleMsg.test.tsx diff --git a/apps/web/src/components/safe-messages/SingleMsg/index.tsx b/apps/web/src/components/safe-messages/SingleMsg/index.tsx new file mode 100644 index 000000000..90fc41d06 --- /dev/null +++ b/apps/web/src/components/safe-messages/SingleMsg/index.tsx @@ -0,0 +1,30 @@ +import { useRouter } from 'next/router' +import { TxListGrid } from '@/components/transactions/TxList' +import { TransactionSkeleton } from '@/components/transactions/TxListItem/ExpandableTransactionItem' +import ExpandableMsgItem from '../MsgListItem/ExpandableMsgItem' +import useSafeMessage from '@/hooks/messages/useSafeMessage' +import ErrorMessage from '@/components/tx/ErrorMessage' + +const SingleMsg = () => { + const router = useRouter() + const { messageHash } = router.query + const safeMessageHash = Array.isArray(messageHash) ? messageHash[0] : messageHash + const [safeMessage, , messageError] = useSafeMessage(safeMessageHash) + + if (safeMessage) { + return ( + + + + ) + } + + if (messageError) { + return Failed to load message + } + + // Loading skeleton + return +} + +export default SingleMsg diff --git a/apps/web/src/components/settings/ContractVersion/index.tsx b/apps/web/src/components/settings/ContractVersion/index.tsx new file mode 100644 index 000000000..2879edab1 --- /dev/null +++ b/apps/web/src/components/settings/ContractVersion/index.tsx @@ -0,0 +1,81 @@ +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 type { MasterCopy } from '@/hooks/useMasterCopies' +import { MasterCopyDeployer, useMasterCopies } from '@/hooks/useMasterCopies' +import useSafeInfo from '@/hooks/useSafeInfo' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import InfoIcon from '@/public/images/notifications/info.svg' +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' + +export const ContractVersion = () => { + const { setTxFlow } = useContext(TxModalContext) + const [masterCopies] = useMasterCopies() + const { safe, safeLoaded } = useSafeInfo() + const currentChain = useCurrentChain() + const masterCopyAddress = safe.implementation.value + + const safeMasterCopy: MasterCopy | undefined = useMemo(() => { + return masterCopies?.find((mc) => sameAddress(mc.address, masterCopyAddress)) + }, [masterCopies, masterCopyAddress]) + + const needsUpdate = safe.implementationVersionState === ImplementationVersionState.OUTDATED + const showUpdateDialog = safeMasterCopy?.deployer === MasterCopyDeployer.GNOSIS && needsUpdate + const isLatestVersion = safe.version && !showUpdateDialog + + const latestSafeVersion = getLatestSafeVersion(currentChain, true) + + return ( + <> + + Contract version + + + + {safeLoaded ? ( + <> + {safe.version ?? 'Unsupported contract'} + {isLatestVersion && ( + <> + Latest version + + )} + + ) : ( + + )} + + + {safeLoaded && safe.version && showUpdateDialog && ( + } + > + + New version is available: {latestSafeVersion} ( + changelog) + + + + Update now to take advantage of new features and the highest security standards available. You will need to + confirm this update just like any other transaction. + + + + {(isOk) => ( + + )} + + + )} + + ) +} diff --git a/src/components/settings/DataManagement/FileListCard.tsx b/apps/web/src/components/settings/DataManagement/FileListCard.tsx similarity index 81% rename from src/components/settings/DataManagement/FileListCard.tsx rename to apps/web/src/components/settings/DataManagement/FileListCard.tsx index 984d373ba..a7e90a547 100644 --- a/src/components/settings/DataManagement/FileListCard.tsx +++ b/apps/web/src/components/settings/DataManagement/FileListCard.tsx @@ -2,17 +2,19 @@ import { Box, Card, CardContent, CardHeader, List, ListItem, ListItemIcon, ListI import type { ListItemTextProps } from '@mui/material' import type { CardHeaderProps } from '@mui/material' import type { ReactElement } from 'react' - import FileIcon from '@/public/images/settings/data/file.svg' + import useChains from '@/hooks/useChains' import { ImportErrors } from '@/components/settings/DataManagement/useGlobalImportFileParser' import type { AddedSafesState } from '@/store/addedSafesSlice' import type { AddressBookState } from '@/store/addressBookSlice' import type { SafeAppsState } from '@/store/safeAppsSlice' import type { SettingsState } from '@/store/settingsSlice' +import type { 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' const getItemSecondaryText = ( chains: ChainInfo[], @@ -51,6 +53,8 @@ type Data = { addressBook?: AddressBookState settings?: SettingsState safeApps?: SafeAppsState + undeployedSafes?: UndeployedSafesState + visitedSafes?: VisitedSafesState error?: string } @@ -65,6 +69,8 @@ const getItems = ({ addressBook, settings, safeApps, + undeployedSafes, + visitedSafes, error, chains, showPreview = false, @@ -75,6 +81,7 @@ const getItems = ({ const addedSafeChainAmount = Object.keys(addedSafes || {}).length const addressBookChainAmount = Object.keys(addressBook || {}).length + const undeployedSafesCount = Object.values(undeployedSafes || {}).flatMap((items) => Object.keys(items)).length const items: Array = [] @@ -116,6 +123,18 @@ const getItems = ({ items.push(settingsPreview) } + if (visitedSafes) { + const visitedSafesPreview: ListItemTextProps = { + primary: ( + <> + Visited Safe Accounts history + + ), + } + + items.push(visitedSafesPreview) + } + const hasBookmarkedSafeApps = Object.values(safeApps || {}).some((chainId) => chainId.pinned?.length > 0) if (hasBookmarkedSafeApps) { const safeAppsPreview: ListItemTextProps = { @@ -129,6 +148,18 @@ const getItems = ({ items.push(safeAppsPreview) } + if (undeployedSafes) { + const undeployedSafesPreview: ListItemTextProps = { + primary: ( + <> + Not activated Safe Accounts {undeployedSafesCount} + + ), + } + + items.push(undeployedSafesPreview) + } + if (items.length === 0) { return [{ primary: <>{ImportErrors.NO_IMPORT_DATA_FOUND} }] } @@ -143,12 +174,24 @@ export const FileListCard = ({ addressBook, settings, safeApps, + undeployedSafes, + visitedSafes, error, showPreview = false, ...cardHeaderProps }: Props): ReactElement => { const chains = useChains() - const items = getItems({ addedSafes, addressBook, settings, safeApps, error, chains: chains.configs, showPreview }) + const items = getItems({ + addedSafes, + addressBook, + settings, + safeApps, + visitedSafes, + undeployedSafes, + error, + chains: chains.configs, + showPreview, + }) return ( diff --git a/src/components/settings/DataManagement/ImportDialog.tsx b/apps/web/src/components/settings/DataManagement/ImportDialog.tsx similarity index 83% rename from src/components/settings/DataManagement/ImportDialog.tsx rename to apps/web/src/components/settings/DataManagement/ImportDialog.tsx index 0a5fe18d4..30b3c7ecc 100644 --- a/src/components/settings/DataManagement/ImportDialog.tsx +++ b/apps/web/src/components/settings/DataManagement/ImportDialog.tsx @@ -1,3 +1,4 @@ +import { undeployedSafesSlice } from '@/features/counterfactual/store/undeployedSafesSlice' import { DialogContent, Alert, AlertTitle, DialogActions, Button, Box, SvgIcon } from '@mui/material' import type { ReactElement, Dispatch, SetStateAction } from 'react' @@ -13,6 +14,7 @@ import { useGlobalImportJsonParser } from '@/components/settings/DataManagement/ import FileIcon from '@/public/images/settings/data/file.svg' import { ImportFileUpload } from '@/components/settings/DataManagement/ImportFileUpload' import { showNotification } from '@/store/notificationsSlice' +import { visitedSafesSlice } from '@/store/visitedSafesSlice' import css from './styles.module.css' @@ -30,10 +32,11 @@ export const ImportDialog = ({ setJsonData: Dispatch> }): ReactElement => { const dispatch = useAppDispatch() - const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount, settings, safeApps, error } = + const { addedSafes, addressBook, addressBookEntriesCount, settings, safeApps, undeployedSafes, visitedSafes, error } = useGlobalImportJsonParser(jsonData) - const isDisabled = (!addedSafes && !addressBook && !settings && !safeApps) || !!error + const isDisabled = + (!addedSafes && !addressBook && !settings && !safeApps && !undeployedSafes && !visitedSafes) || !!error const handleClose = () => { setFileName(undefined) @@ -67,6 +70,16 @@ export const ImportDialog = ({ trackEvent(SETTINGS_EVENTS.DATA.IMPORT_SAFE_APPS) } + if (undeployedSafes) { + dispatch(undeployedSafesSlice.actions.addUndeployedSafes(undeployedSafes)) + trackEvent(SETTINGS_EVENTS.DATA.IMPORT_UNDEPLOYED_SAFES) + } + + if (visitedSafes) { + dispatch(visitedSafesSlice.actions.setVisitedSafes(visitedSafes)) + trackEvent(SETTINGS_EVENTS.DATA.IMPORT_VISITED_SAFES) + } + dispatch( showNotification({ variant: 'success', @@ -104,6 +117,8 @@ export const ImportDialog = ({ addressBook={addressBook} settings={settings} safeApps={safeApps} + visitedSafes={visitedSafes} + undeployedSafes={undeployedSafes} error={error} showPreview /> diff --git a/src/components/settings/DataManagement/ImportFileUpload.tsx b/apps/web/src/components/settings/DataManagement/ImportFileUpload.tsx similarity index 89% rename from src/components/settings/DataManagement/ImportFileUpload.tsx rename to apps/web/src/components/settings/DataManagement/ImportFileUpload.tsx index 4eb4d6c7e..e123b0e37 100644 --- a/src/components/settings/DataManagement/ImportFileUpload.tsx +++ b/apps/web/src/components/settings/DataManagement/ImportFileUpload.tsx @@ -5,6 +5,7 @@ import type { Dispatch, SetStateAction } from 'react' import FileUpload, { FileTypes } from '@/components/common/FileUpload' import InfoIcon from '@/public/images/notifications/info.svg' +import { BRAND_NAME } from '@/config/constants' const AcceptedMimeTypes = { 'application/json': ['.json'], @@ -52,7 +53,7 @@ export const ImportFileUpload = ({ return ( <> - Import {'Rootstock Safe'} data by uploading a file in the area below. + Import {BRAND_NAME} data by uploading a file in the area below. - Only JSON files exported from the {'Rootstock Safe'} can be imported. + Only JSON files exported from the {BRAND_NAME} can be imported. ) diff --git a/src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts b/apps/web/src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts similarity index 100% rename from src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts rename to apps/web/src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts diff --git a/apps/web/src/components/settings/DataManagement/index.tsx b/apps/web/src/components/settings/DataManagement/index.tsx new file mode 100644 index 000000000..b66224136 --- /dev/null +++ b/apps/web/src/components/settings/DataManagement/index.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react' +import { Paper, Grid, Typography, Button, SvgIcon, Box } from '@mui/material' + +import FileIcon from '@/public/images/settings/data/file.svg' +import ExportIcon from '@/public/images/common/export.svg' +import { getPersistedState, useAppSelector } from '@/store' +import { addressBookSlice, selectAllAddressBooks } from '@/store/addressBookSlice' +import { addedSafesSlice, selectAllAddedSafes } from '@/store/addedSafesSlice' +import { safeAppsSlice, selectSafeApps } from '@/store/safeAppsSlice' +import { selectSettings, settingsSlice } from '@/store/settingsSlice' +import { selectUndeployedSafes, undeployedSafesSlice } from '@/features/counterfactual/store/undeployedSafesSlice' +import { ImportFileUpload } from '@/components/settings/DataManagement/ImportFileUpload' +import { ImportDialog } from '@/components/settings/DataManagement/ImportDialog' +import { SAFE_EXPORT_VERSION } from '@/components/settings/DataManagement/useGlobalImportFileParser' +import { FileListCard } from '@/components/settings/DataManagement/FileListCard' +import { selectAllVisitedSafes, visitedSafesSlice } from '@/store/visitedSafesSlice' + +import css from './styles.module.css' +import Track from '@/components/common/Track' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' + +const getExportFileName = () => { + const today = new Date().toISOString().slice(0, 10) + return `safe-${today}.json` +} + +export const exportAppData = () => { + // Extract the slices we want to export + const { + [addressBookSlice.name]: addressBook, + [addedSafesSlice.name]: addedSafes, + [settingsSlice.name]: setting, + [safeAppsSlice.name]: safeApps, + [undeployedSafesSlice.name]: undeployedSafes, + [visitedSafesSlice.name]: visitedSafes, + } = getPersistedState() + + // Ensure they are under the same name as the slice + const exportData = { + [addressBookSlice.name]: addressBook, + [addedSafesSlice.name]: addedSafes, + [settingsSlice.name]: setting, + [safeAppsSlice.name]: safeApps, + [undeployedSafesSlice.name]: undeployedSafes, + [visitedSafesSlice.name]: visitedSafes, + } + + const data = JSON.stringify({ version: SAFE_EXPORT_VERSION.V3, data: exportData }) + + const blob = new Blob([data], { type: 'text/json' }) + const link = document.createElement('a') + + link.download = getExportFileName() + link.href = window.URL.createObjectURL(blob) + link.dataset.downloadurl = ['text/json', link.download, link.href].join(':') + link.dispatchEvent(new MouseEvent('click')) +} + +const DataManagement = () => { + const [exportFileName, setExportFileName] = useState('') + const [importFileName, setImportFileName] = useState() + const [jsonData, setJsonData] = useState() + + const addedSafes = useAppSelector(selectAllAddedSafes) + const addressBook = useAppSelector(selectAllAddressBooks) + const settings = useAppSelector(selectSettings) + const visitedSafes = useAppSelector(selectAllVisitedSafes) + const safeApps = useAppSelector(selectSafeApps) + const undeployedSafes = useAppSelector(selectUndeployedSafes) + + useEffect(() => { + // Prevent hydration errors + setExportFileName(getExportFileName()) + }, []) + + return ( + <> + + + + + Data export + + + + + Download your local data with your added Safe Accounts, address book and settings. + + `${shape.borderRadius}px` }}> + + + } + title={{exportFileName}} + action={ + + + + } + addedSafes={addedSafes} + addressBook={addressBook} + settings={settings} + visitedSafes={visitedSafes} + safeApps={safeApps} + undeployedSafes={undeployedSafes} + /> + + + + + + + + + Data import + + + + + + + + {jsonData && ( + + )} + + + + ) +} + +export default DataManagement diff --git a/src/components/settings/DataManagement/styles.module.css b/apps/web/src/components/settings/DataManagement/styles.module.css similarity index 100% rename from src/components/settings/DataManagement/styles.module.css rename to apps/web/src/components/settings/DataManagement/styles.module.css diff --git a/src/components/settings/DataManagement/useGlobalImportFileParser.ts b/apps/web/src/components/settings/DataManagement/useGlobalImportFileParser.ts similarity index 79% rename from src/components/settings/DataManagement/useGlobalImportFileParser.ts rename to apps/web/src/components/settings/DataManagement/useGlobalImportFileParser.ts index ff5c081e9..f0362e717 100644 --- a/src/components/settings/DataManagement/useGlobalImportFileParser.ts +++ b/apps/web/src/components/settings/DataManagement/useGlobalImportFileParser.ts @@ -7,16 +7,19 @@ 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' export const enum SAFE_EXPORT_VERSION { V1 = '1.0', V2 = '2.0', + V3 = '3.0', } export enum ImportErrors { - INVALID_VERSION = 'The file is not a Rootstock Safe export.', + INVALID_VERSION = 'The file is not a valid export.', INVALID_JSON_FORMAT = 'The JSON format is invalid.', NO_IMPORT_DATA_FOUND = 'This file contains no importable data.', } @@ -58,6 +61,13 @@ export const _filterValidAbEntries = (ab?: AddressBookState): AddressBookState | * - safeApps * - settings * + * 3.0: + * - address book + * - added Safes + * - safeApps + * - settings + * - visited Safes + * * @param jsonData * @returns data to import and some insights about it */ @@ -67,6 +77,8 @@ type Data = { addressBook?: AddressBookState settings?: SettingsState safeApps?: SafeAppsState + undeployedSafes?: UndeployedSafesState + visitedSafes?: VisitedSafesState error?: ImportErrors addressBookEntriesCount: number addedSafesCount: number @@ -81,6 +93,8 @@ export const useGlobalImportJsonParser = (jsonData: string | undefined): Data => addedSafes: undefined, settings: undefined, safeApps: undefined, + undeployedSafes: undefined, + visitedSafes: undefined, } if (!jsonData) { @@ -116,6 +130,18 @@ export const useGlobalImportJsonParser = (jsonData: string | undefined): Data => data.addedSafes = parsedFile.data.addedSafes data.settings = parsedFile.data.settings data.safeApps = parsedFile.data.safeApps + data.undeployedSafes = parsedFile.data.undeployedSafes + + break + } + + case SAFE_EXPORT_VERSION.V3: { + data.addressBook = _filterValidAbEntries(parsedFile.data.addressBook) + data.addedSafes = parsedFile.data.addedSafes + data.settings = parsedFile.data.settings + data.safeApps = parsedFile.data.safeApps + data.undeployedSafes = parsedFile.data.undeployedSafes + data.visitedSafes = parsedFile.data.visitedSafes break } diff --git a/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx b/apps/web/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx similarity index 100% rename from src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx rename to apps/web/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx diff --git a/src/components/settings/EnvironmentVariables/EnvHintButton/styles.module.css b/apps/web/src/components/settings/EnvironmentVariables/EnvHintButton/styles.module.css similarity index 100% rename from src/components/settings/EnvironmentVariables/EnvHintButton/styles.module.css rename to apps/web/src/components/settings/EnvironmentVariables/EnvHintButton/styles.module.css diff --git a/apps/web/src/components/settings/EnvironmentVariables/index.tsx b/apps/web/src/components/settings/EnvironmentVariables/index.tsx new file mode 100644 index 000000000..479da4ce1 --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentVariables/index.tsx @@ -0,0 +1,252 @@ +import { useForm, FormProvider } from 'react-hook-form' +import { Paper, Grid, Typography, TextField, Button, Tooltip, IconButton, SvgIcon } from '@mui/material' +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' + +export enum EnvVariablesField { + rpc = 'rpc', + tenderlyURL = 'tenderlyURL', + tenderlyToken = 'tenderlyToken', +} + +export type EnvVariablesFormData = { + [EnvVariablesField.rpc]: string + [EnvVariablesField.tenderlyURL]: string + [EnvVariablesField.tenderlyToken]: string +} + +const EnvironmentVariables = () => { + const chainId = useChainId() + const chain = useCurrentChain() + const settings = useAppSelector(selectSettings) + const dispatch = useAppDispatch() + + const formMethods = useForm({ + mode: 'onChange', + values: { + [EnvVariablesField.rpc]: settings.env?.rpc[chainId] ?? '', + [EnvVariablesField.tenderlyURL]: settings.env?.tenderly.url ?? '', + [EnvVariablesField.tenderlyToken]: settings.env?.tenderly.accessToken ?? '', + }, + }) + + const { register, handleSubmit, setValue, watch } = formMethods + + const rpc = watch(EnvVariablesField.rpc) + const tenderlyURL = watch(EnvVariablesField.tenderlyURL) + const tenderlyToken = watch(EnvVariablesField.tenderlyToken) + + const onSubmit = handleSubmit((data) => { + trackEvent({ ...SETTINGS_EVENTS.ENV_VARIABLES.SAVE }) + + dispatch( + setRpc({ + chainId, + rpc: data[EnvVariablesField.rpc], + }), + ) + + dispatch( + setTenderly({ + url: data[EnvVariablesField.tenderlyURL], + accessToken: data[EnvVariablesField.tenderlyToken], + }), + ) + + location.reload() + }) + + const onReset = (name: EnvVariablesField) => { + setValue(name, '') + } + + return ( + + + + + Environment variables + + + + + + You can override some of our default APIs here in case you need to. Proceed at your own risk. + + + +
+ + RPC provider + + + + + + + + + + onReset(EnvVariablesField.rpc)} size="small" color="primary"> + + + + + ) : null, + }} + fullWidth + /> + + + Tenderly + + You can use your own Tenderly project to keep track of all your transaction simulations.{' '} + + Read more + + + } + > + + + + + + + + + + + onReset(EnvVariablesField.tenderlyURL)} + size="small" + color="primary" + > + + + + + ) : null, + }} + fullWidth + /> + + + + + + onReset(EnvVariablesField.tenderlyToken)} + size="small" + color="primary" + > + + + + + ) : null, + }} + fullWidth + /> + + + + + +
+
+
+
+ ) +} + +export default EnvironmentVariables diff --git a/apps/web/src/components/settings/FallbackHandler/__tests__/index.test.tsx b/apps/web/src/components/settings/FallbackHandler/__tests__/index.test.tsx new file mode 100644 index 000000000..af60dbc2f --- /dev/null +++ b/apps/web/src/components/settings/FallbackHandler/__tests__/index.test.tsx @@ -0,0 +1,288 @@ +import { TWAP_FALLBACK_HANDLER } from '@/features/swap/helpers/utils' +import { chainBuilder } from '@/tests/builders/chains' +import { render, waitFor } from '@/tests/test-utils' + +import * as useSafeInfoHook from '@/hooks/useSafeInfo' +import * as useChains from '@/hooks/useChains' +import * as useTxBuilderHook from '@/hooks/safe-apps/useTxBuilderApp' +import { FallbackHandler } from '..' + +const GOERLI_FALLBACK_HANDLER = '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4' + +const mockChain = chainBuilder().with({ chainId: '1' }).build() + +describe('FallbackHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + + jest.spyOn(useTxBuilderHook, 'useTxBuilderApp').mockImplementation(() => ({ + link: { href: 'https://mock.link/tx-builder' }, + })) + + jest.spyOn(useChains, 'useCurrentChain').mockReturnValue(mockChain) + }) + + it('should render the Fallback Handler when one is set', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '1', + fallbackHandler: { + value: GOERLI_FALLBACK_HANDLER, + name: 'FallbackHandlerName', + }, + }, + }) as unknown as ReturnType, + ) + + const fbHandler = render(, { + initialReduxState: { chains: { loading: false, data: [mockChain] } }, + }) + + await waitFor(() => { + expect( + fbHandler.queryByText( + 'The fallback handler adds fallback logic for funtionality that may not be present in the Safe contract. Learn more about the fallback handler', + ), + ).toBeDefined() + + expect(fbHandler.getByText(GOERLI_FALLBACK_HANDLER)).toBeDefined() + + expect(fbHandler.getByText('FallbackHandlerName')).toBeDefined() + }) + }) + + it('should render the Fallback Handler without warning when one that is not a default address is set', async () => { + const OPTIMISM_FALLBACK_HANDLER = '0x69f4D1788e39c87893C980c06EdF4b7f686e2938' + + // Optimism is not a "default" address + expect(OPTIMISM_FALLBACK_HANDLER).not.toBe(GOERLI_FALLBACK_HANDLER) + + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '10', + fallbackHandler: { + value: OPTIMISM_FALLBACK_HANDLER, + name: 'FallbackHandlerName', + }, + }, + }) as unknown as ReturnType, + ) + + const fbHandler = render(, { + initialReduxState: { chains: { loading: false, data: [mockChain] } }, + }) + + await waitFor(() => { + expect( + fbHandler.queryByText( + 'The fallback handler adds fallback logic for funtionality that may not be present in the Safe contract. Learn more about the fallback handler', + ), + ).toBeDefined() + + expect(fbHandler.getByText(OPTIMISM_FALLBACK_HANDLER)).toBeDefined() + + expect(fbHandler.getByText('FallbackHandlerName')).toBeDefined() + + expect(fbHandler.queryByText('An unofficial fallback handler is currently set.')).not.toBeInTheDocument() + }) + }) + + it('should use the official deployment name if the address is official but no known name is present', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + fallbackHandler: { + value: GOERLI_FALLBACK_HANDLER, + }, + }, + }) as unknown as ReturnType, + ) + + const fbHandler = render(, { + initialReduxState: { chains: { loading: false, data: [mockChain] } }, + }) + + await waitFor(() => { + expect(fbHandler.getByText('CompatibilityFallbackHandler')).toBeDefined() + }) + }) + + describe('No Fallback Handler', () => { + it('should render a warning when no Fallback Handler is set', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + }, + }) as unknown as ReturnType, + ) + + const fbHandler = render() + + await waitFor(() => { + expect( + fbHandler.queryByText( + new RegExp('The Rootstock Safe may not work correctly as no fallback handler is currently set.'), + ), + ).toBeInTheDocument() + expect(fbHandler.queryByText('Transaction Builder')).toBeInTheDocument() + }) + }) + + it('should conditionally append the Transaction Builder link', async () => { + jest.spyOn(useTxBuilderHook, 'useTxBuilderApp').mockImplementation(() => undefined) + + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + }, + }) as unknown as ReturnType, + ) + + const fbHandler = render() + + await waitFor(() => { + expect( + fbHandler.queryByText( + new RegExp('The Rootstock Safe may not work correctly as no fallback handler is currently set.'), + ), + ).toBeInTheDocument() + expect(fbHandler.queryByText('Transaction Builder')).not.toBeInTheDocument() + }) + }) + }) + + describe('Unofficial Fallback Handler', () => { + it('should render placeholder and warning when an unofficial Fallback Handler is set', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + fallbackHandler: { + value: '0x123', + }, + }, + }) as unknown as ReturnType, + ) + + const fbHandler = render() + + await waitFor(() => { + expect( + fbHandler.queryByText( + 'The fallback handler adds fallback logic for funtionality that may not be present in the Safe Account contract. Learn more about the fallback handler', + ), + ).toBeDefined() + + expect(fbHandler.getByText('0x123')).toBeDefined() + }) + + await waitFor(() => { + expect(fbHandler.queryByText(new RegExp('An unofficial fallback handler is currently set.'))) + expect(fbHandler.queryByText('Transaction Builder')).toBeInTheDocument() + }) + }) + + it('should conditionally append the Transaction Builder link', async () => { + jest.spyOn(useTxBuilderHook, 'useTxBuilderApp').mockImplementation(() => undefined) + + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + fallbackHandler: { + value: '0x123', + }, + }, + }) as unknown as ReturnType, + ) + + const fbHandler = render() + + await waitFor(() => { + expect(fbHandler.queryByText(new RegExp('An unofficial fallback handler is currently set.'))) + expect(fbHandler.queryByText('Transaction Builder')).not.toBeInTheDocument() + }) + }) + }) + + it('should render nothing if the Safe Account version does not support Fallback Handlers', () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.0.0', + chainId: '5', + }, + }) as unknown as ReturnType, + ) + + const fbHandler = render() + + expect(fbHandler.container).toBeEmptyDOMElement() + }) + + it('should display a message in case it is a TWAP fallback handler', () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '1', + fallbackHandler: { + value: TWAP_FALLBACK_HANDLER, + }, + }, + }) as unknown as ReturnType, + ) + + const { getByText } = render() + + expect( + getByText( + "This is CoW's fallback handler. It is needed for this Safe to be able to use the TWAP feature for Swaps.", + ), + ).toBeInTheDocument() + }) + + it('should not display a message in case it is a TWAP fallback handler on an unsupported network', () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '10', + fallbackHandler: { + value: TWAP_FALLBACK_HANDLER, + }, + }, + }) as unknown as ReturnType, + ) + + const { queryByText } = render() + + expect( + queryByText( + "This is CoW's fallback handler. It is needed for this Safe to be able to use the TWAP feature for Swaps.", + ), + ).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/settings/FallbackHandler/index.tsx b/apps/web/src/components/settings/FallbackHandler/index.tsx new file mode 100644 index 000000000..700358e79 --- /dev/null +++ b/apps/web/src/components/settings/FallbackHandler/index.tsx @@ -0,0 +1,133 @@ +import NextLink from 'next/link' +import { Typography, Box, Grid, Paper, Link, Alert } from '@mui/material' +import semverSatisfies from 'semver/functions/satisfies' +import type { ReactElement } from 'react' + +import EthHashInfo from '@/components/common/EthHashInfo' +import useSafeInfo from '@/hooks/useSafeInfo' +import { BRAND_NAME, HelpCenterArticle } 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 { useIsTWAPFallbackHandler } from '@/features/swap/hooks/useIsTWAPFallbackHandler' + +const FALLBACK_HANDLER_VERSION = '>=1.1.1' + +export const FallbackHandlerWarning = ({ + message, + txBuilderLinkPrefix = 'It can be altered via the', +}: { + message: ReactElement | string + txBuilderLinkPrefix?: string +}) => { + const txBuilder = useTxBuilderApp() + return ( + <> + {message} + {!!txBuilder && !!txBuilderLinkPrefix && ( + <> + {` ${txBuilderLinkPrefix} `} + + Transaction Builder + + . + + )} + + ) +} + +export const FallbackHandler = (): ReactElement | null => { + const { safe } = useSafeInfo() + const fallbackHandlerDeployments = useCompatibilityFallbackHandlerDeployments() + const isOfficial = useIsOfficialFallbackHandler() + const isTWAPFallbackHandler = useIsTWAPFallbackHandler() + + const supportsFallbackHandler = !!safe.version && semverSatisfies(safe.version, FALLBACK_HANDLER_VERSION) + + if (!supportsFallbackHandler) { + return null + } + + const hasFallbackHandler = !!safe.fallbackHandler + + const warning = !hasFallbackHandler ? ( + + ) : isTWAPFallbackHandler ? ( + <>This is CoW's fallback handler. It is needed for this Safe to be able to use the TWAP feature for Swaps. + ) : !isOfficial ? ( + + An unofficial fallback handler is currently set. + + } + /> + ) : undefined + + return ( + + + + + Fallback handler + + + + + + + The fallback handler adds fallback logic for funtionality that may not be present in the Safe Account + contract. Learn more about the fallback handler{' '} + here + + + + {warning && ( + + {warning} + + )} + + {safe.fallbackHandler && ( + + )} + + + + + + ) +} diff --git a/apps/web/src/components/settings/ProposersList/index.tsx b/apps/web/src/components/settings/ProposersList/index.tsx new file mode 100644 index 000000000..27042a004 --- /dev/null +++ b/apps/web/src/components/settings/ProposersList/index.tsx @@ -0,0 +1,134 @@ +import { Chip } from '@/components/common/Chip' +import EnhancedTable from '@/components/common/EnhancedTable' +import tableCss from '@/components/common/EnhancedTable/styles.module.css' +import OnlyOwner from '@/components/common/OnlyOwner' +import Track from '@/components/common/Track' +import UpsertProposer from '@/features/proposers/components/UpsertProposer' +import DeleteProposerDialog from '@/features/proposers/components/DeleteProposerDialog' +import EditProposerDialog from '@/features/proposers/components/EditProposerDialog' +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' + +const headCells = [ + { + id: 'proposer', + label: 'Proposer', + }, + { + id: 'creator', + label: 'Creator', + }, + { + id: 'Actions', + label: '', + }, +] + +const ProposersList = () => { + const [isAddDialogOpen, setIsAddDialogOpen] = useState() + const proposers = useProposers() + const isEnabled = useHasFeature(FEATURES.PROPOSERS) + + const rows = useMemo(() => { + if (!proposers.data) return [] + + return proposers.data.results.map((proposer) => { + return { + cells: { + proposer: { + rawValue: proposer.delegate, + content: ( + + ), + }, + + creator: { + rawValue: proposer.delegator, + content: , + }, + actions: { + rawValue: '', + sticky: true, + content: isEnabled && ( +
+ + +
+ ), + }, + }, + } + }) + }, [isEnabled, proposers.data]) + + if (!proposers.data?.results) return null + + const onAdd = () => { + setIsAddDialogOpen(true) + } + + return ( + + + + + + + + + + Proposers + + + Proposers can suggest transactions but cannot approve or execute them. Signers should review and approve + transactions first. Learn more + + + {isEnabled && ( + + + {(isOk) => ( + + + + )} + + + )} + + {rows.length > 0 && } + + + {isAddDialogOpen && ( + setIsAddDialogOpen(false)} onSuccess={() => setIsAddDialogOpen(false)} /> + )} + + + + ) +} + +export default ProposersList diff --git a/src/components/settings/PushNotifications/GlobalPushNotifications.tsx b/apps/web/src/components/settings/PushNotifications/GlobalPushNotifications.tsx similarity index 94% rename from src/components/settings/PushNotifications/GlobalPushNotifications.tsx rename to apps/web/src/components/settings/PushNotifications/GlobalPushNotifications.tsx index c0374ddb5..c08de91b2 100644 --- a/src/components/settings/PushNotifications/GlobalPushNotifications.tsx +++ b/apps/web/src/components/settings/PushNotifications/GlobalPushNotifications.tsx @@ -33,12 +33,15 @@ import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notif import { requestNotificationPermission } from './logic' import type { NotifiableSafes } from './logic' import type { PushNotificationPreferences } from '@/services/push-notifications/preferences' -import CheckWallet from '@/components/common/CheckWallet' +import CheckWalletWithPermission from '@/components/common/CheckWalletWithPermission' +import { Permission } from '@/permissions/types' import css from './styles.module.css' -import useAllOwnedSafes from '@/components/welcome/MyAccounts/useAllOwnedSafes' +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 { useNotificationsRenewal } from './hooks/useNotificationsRenewal' // UI logic @@ -267,6 +270,8 @@ export const GlobalPushNotifications = (): ReactElement | null => { const { unregisterDeviceNotifications, unregisterSafeNotifications, registerNotifications } = useNotificationRegistrations() + const { safesForRenewal } = useNotificationsRenewal() + // Safes selected in the UI const [selectedSafes, setSelectedSafes] = useState({}) @@ -348,7 +353,11 @@ export const GlobalPushNotifications = (): ReactElement | null => { const registrationPromises: Array> = [] - const safesToRegister = _getSafesToRegister(selectedSafes, currentNotifiedSafes) + const newlySelectedSafes = _getSafesToRegister(selectedSafes, currentNotifiedSafes) + + // Merge Safes that need to be registered with the ones for which notifications need to be renewed + const safesToRegister = _mergeNotifiableSafes(newlySelectedSafes, {}, safesForRenewal) + if (safesToRegister) { registrationPromises.push(registerNotifications(safesToRegister)) } @@ -374,7 +383,7 @@ export const GlobalPushNotifications = (): ReactElement | null => { if (totalNotifiableSafes === 0) { return ( - palette.primary.light}> + palette.primary.light }}> {address ? 'No owned Safes' : 'No wallet connected'} ) @@ -391,17 +400,17 @@ export const GlobalPushNotifications = (): ReactElement | null => { {totalSignaturesRequired > 0 && ( We'll ask you to verify ownership of each Safe Account with your signature per chain{' '} - {totalSignaturesRequired} time{totalSignaturesRequired > 1 ? 's' : ''} + {totalSignaturesRequired} time{maybePlural(totalSignaturesRequired)} )} - + {(isOk) => ( )} - +
diff --git a/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts b/apps/web/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts similarity index 100% rename from src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts rename to apps/web/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts diff --git a/src/components/settings/PushNotifications/__tests__/logic.test.ts b/apps/web/src/components/settings/PushNotifications/__tests__/logic.test.ts similarity index 99% rename from src/components/settings/PushNotifications/__tests__/logic.test.ts rename to apps/web/src/components/settings/PushNotifications/__tests__/logic.test.ts index cb5993237..7a103f5b1 100644 --- a/src/components/settings/PushNotifications/__tests__/logic.test.ts +++ b/apps/web/src/components/settings/PushNotifications/__tests__/logic.test.ts @@ -123,7 +123,7 @@ describe('Notifications', () => { const mockProvider = new BrowserProvider(MockEip1193Provider) - jest.spyOn(mockProvider, 'getSigner').mockImplementation((address?: string | number | undefined) => + jest.spyOn(mockProvider, 'getSigner').mockImplementation(() => Promise.resolve({ signMessage: jest.fn().mockResolvedValueOnce(MM_SIGNATURE), } as unknown as JsonRpcSigner), diff --git a/apps/web/src/components/settings/PushNotifications/constants.ts b/apps/web/src/components/settings/PushNotifications/constants.ts new file mode 100644 index 000000000..05b37ee22 --- /dev/null +++ b/apps/web/src/components/settings/PushNotifications/constants.ts @@ -0,0 +1,3 @@ +export const RENEWAL_NOTIFICATION_KEY = 'renewal' +export const RENEWAL_MESSAGE = + 'We’ve upgraded your notification experience! To continue receiving important updates seamlessly, you’ll need to sign this message on every chain you use.' diff --git a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts similarity index 87% rename from src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts rename to apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts index 607c1a21c..3e457c521 100644 --- a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts @@ -187,6 +187,71 @@ describe('useNotificationPreferences', () => { }) }) + describe('getChainPreferences', () => { + const chainId1 = '1' + const safeAddress1 = toBeHex('0x1', 20) + const safeAddress2 = toBeHex('0x2', 20) + + const chainId2 = '2' + + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + it('should return existing chain preferences', async () => { + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.getChainPreferences(chainId1)).toEqual([ + { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + ]) + }) + }) + + it('should return an empty array if no preferences exist for the chain', async () => { + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.getChainPreferences('3')).toEqual([]) + }) + }) + + it('should return an empty array if no preferences exist', async () => { + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.getChainPreferences('1')).toEqual([]) + }) + }) + }) + describe('createPreferences', () => { it('should create preferences, then hydrate the preferences state', async () => { const { result } = renderHook(() => useNotificationPreferences()) diff --git a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts similarity index 86% rename from src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts rename to apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts index c0a6cb106..26313daee 100644 --- a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts @@ -8,13 +8,16 @@ import * as web3 from '@/hooks/wallets/web3' import * as wallet from '@/hooks/wallets/useWallet' import * as logic from '../../logic' import * as preferences from '../useNotificationPreferences' +import * as tokenVersion from '../useNotificationsTokenVersion' import * as notificationsSlice from '@/store/notificationsSlice' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import { MockEip1193Provider } from '@/tests/mocks/providers' +import { NotificationsTokenVersion } from '@/services/push-notifications/preferences' jest.mock('@safe-global/safe-gateway-typescript-sdk') jest.mock('../useNotificationPreferences') +jest.mock('../useNotificationsTokenVersion') Object.defineProperty(globalThis, 'crypto', { value: { @@ -28,14 +31,24 @@ describe('useNotificationRegistrations', () => { }) describe('registerNotifications', () => { + const setTokenVersionMock = jest.fn() + beforeEach(() => { const mockProvider = new BrowserProvider(MockEip1193Provider) jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) + jest + .spyOn(tokenVersion, 'useNotificationsTokenVersion') + .mockImplementation( + () => + ({ setTokenVersion: setTokenVersionMock }) as unknown as ReturnType< + typeof tokenVersion.useNotificationsTokenVersion + >, + ) jest.spyOn(wallet, 'default').mockImplementation( () => ({ label: 'MetaMask', - } as ConnectedWallet), + }) as ConnectedWallet, ) }) @@ -75,7 +88,7 @@ describe('useNotificationRegistrations', () => { () => ({ uuid: undefined, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -83,6 +96,7 @@ describe('useNotificationRegistrations', () => { await result.current.registerNotifications({}) expect(registerDeviceSpy).not.toHaveBeenCalled() + expect(setTokenVersionMock).not.toHaveBeenCalled() }) it('does not create preferences/notify if registration does not succeed', async () => { @@ -105,7 +119,7 @@ describe('useNotificationRegistrations', () => { ({ uuid: self.crypto.randomUUID(), createPreferences: createPreferencesMock, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -115,6 +129,7 @@ describe('useNotificationRegistrations', () => { expect(registerDeviceSpy).toHaveBeenCalledWith(payload) expect(createPreferencesMock).not.toHaveBeenCalled() + expect(setTokenVersionMock).not.toHaveBeenCalled() }) it('does not create preferences/notify if registration throws', async () => { @@ -137,7 +152,7 @@ describe('useNotificationRegistrations', () => { ({ uuid: self.crypto.randomUUID(), createPreferences: createPreferencesMock, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -147,6 +162,7 @@ describe('useNotificationRegistrations', () => { expect(registerDeviceSpy).toHaveBeenCalledWith(payload) expect(createPreferencesMock).not.toHaveBeenCalledWith() + expect(setTokenVersionMock).not.toHaveBeenCalledWith() }) it('creates preferences/notifies if registration succeeded', async () => { @@ -168,7 +184,7 @@ describe('useNotificationRegistrations', () => { ({ uuid: self.crypto.randomUUID(), createPreferences: createPreferencesMock, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const showNotificationSpy = jest.spyOn(notificationsSlice, 'showNotification') @@ -181,6 +197,9 @@ describe('useNotificationRegistrations', () => { expect(createPreferencesMock).toHaveBeenCalled() + expect(setTokenVersionMock).toHaveBeenCalledTimes(1) + expect(setTokenVersionMock).toHaveBeenCalledWith(NotificationsTokenVersion.V2, safesToRegister) + expect(showNotificationSpy).toHaveBeenCalledWith({ groupKey: 'notifications', message: 'You will now receive notifications for these Safe Accounts in your browser.', @@ -197,7 +216,7 @@ describe('useNotificationRegistrations', () => { () => ({ uuid: undefined, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -219,7 +238,7 @@ describe('useNotificationRegistrations', () => { ({ uuid, deletePreferences: deletePreferencesMock, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -245,7 +264,7 @@ describe('useNotificationRegistrations', () => { ({ uuid, deletePreferences: deletePreferencesMock, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -271,7 +290,7 @@ describe('useNotificationRegistrations', () => { ({ uuid, deletePreferences: deletePreferencesMock, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -295,7 +314,7 @@ describe('useNotificationRegistrations', () => { () => ({ uuid: undefined, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -317,7 +336,7 @@ describe('useNotificationRegistrations', () => { ({ uuid, deleteAllChainPreferences: deleteAllChainPreferencesMock, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -340,7 +359,7 @@ describe('useNotificationRegistrations', () => { ({ uuid, deleteAllChainPreferences: deleteAllChainPreferencesMock, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) @@ -363,7 +382,7 @@ describe('useNotificationRegistrations', () => { ({ uuid, deleteAllChainPreferences: deleteAllChainPreferencesMock, - } as unknown as ReturnType), + }) as unknown as ReturnType, ) const { result } = renderHook(() => useNotificationRegistrations()) diff --git a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationTracking.test.ts b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationTracking.test.ts similarity index 98% rename from src/components/settings/PushNotifications/hooks/__tests__/useNotificationTracking.test.ts rename to apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationTracking.test.ts index 1e8e834ea..f8939d45d 100644 --- a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationTracking.test.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationTracking.test.ts @@ -71,7 +71,7 @@ describe('useNotificationTracking', () => { }) const _entries = await entries(createNotificationTrackingIndexedDb()) - expect(Object.fromEntries(_entries)).toStrictEqual({ + expect(Object.fromEntries(_entries)).toEqual({ [`1:${WebhookType.INCOMING_ETHER}`]: { shown: 0, opened: 0, diff --git a/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationsRenewal.test.ts b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationsRenewal.test.ts new file mode 100644 index 000000000..d4d458993 --- /dev/null +++ b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationsRenewal.test.ts @@ -0,0 +1,300 @@ +import { toBeHex } from 'ethers' +import { useNotificationsRenewal } from '../useNotificationsRenewal' +import * as useSafeInfoHook from '@/hooks/useSafeInfo' +import * as store from '@/store' +import * as useNotificationsTokenVersion from '../useNotificationsTokenVersion' +import * as useNotificationRegistrations from '../useNotificationRegistrations' +import * as useNotificationPreferences from '../useNotificationPreferences' +import * as notificationsSlice from '@/store/notificationsSlice' +import { NotificationsTokenVersion } from '@/services/push-notifications/preferences' +import { renderHook, waitFor } from '@testing-library/react' +import { RENEWAL_NOTIFICATION_KEY } from '../../constants' + +const { V1, V2 } = NotificationsTokenVersion + +describe('useNotificationsRenewal', () => { + const chainId1 = '123' + const chainId2 = '234' + + const safeAddress1 = toBeHex('0x1', 20) + const safeAddress2 = toBeHex('0x2', 20) + const safeAddress3 = toBeHex('0x3', 20) + + const useSafeInfoSpy = jest.spyOn(useSafeInfoHook, 'default') + const useAppDispatchSpy = jest.spyOn(store, 'useAppDispatch') + const useNotificationsTokenVersionSpy = jest.spyOn(useNotificationsTokenVersion, 'useNotificationsTokenVersion') + const useNotificationRegistrationsSpy = jest.spyOn(useNotificationRegistrations, 'useNotificationRegistrations') + const useNotificationPreferencesSpy = jest.spyOn(useNotificationPreferences, 'useNotificationPreferences') + const useIsNotificationsRenewalEnabledSpy = jest.spyOn( + useNotificationsTokenVersion, + 'useIsNotificationsRenewalEnabled', + ) + const showNotificationSpy = jest.spyOn(notificationsSlice, 'showNotification') + const selectNotificationsSpy = jest.spyOn(notificationsSlice, 'selectNotifications') + + const dispatchMock = jest.fn() + const registerNotificationsMock = jest.fn().mockResolvedValue(undefined) + const preferencesMock = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: useNotificationPreferences.DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: useNotificationPreferences.DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress3}`]: { + chainId: chainId1, + safeAddress: safeAddress3, + preferences: useNotificationPreferences.DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: useNotificationPreferences.DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + const getAllPreferencesMock = jest.fn().mockReturnValue(preferencesMock) + const getChainPreferencesMock = jest + .fn() + .mockReturnValue([ + preferencesMock[`${chainId1}:${safeAddress1}`], + preferencesMock[`${chainId1}:${safeAddress2}`], + preferencesMock[`${chainId1}:${safeAddress3}`], + ]) + + const notificationsTokenVersionMock = { + safeTokenVersion: undefined, + allTokenVersions: { [chainId1]: { [safeAddress2]: V2, [safeAddress3]: V1 }, [chainId2]: { [safeAddress1]: V1 } }, + setTokenVersion: jest.fn(), + } + + afterEach(() => { + jest.clearAllMocks() + }) + + beforeEach(() => { + useSafeInfoSpy.mockReturnValue({ + safe: { + chainId: chainId1, + address: { value: safeAddress1 }, + }, + safeLoaded: true, + } as unknown as ReturnType) + + useNotificationRegistrationsSpy.mockReturnValue({ + registerNotifications: registerNotificationsMock, + } as unknown as ReturnType<(typeof useNotificationRegistrations)['useNotificationRegistrations']>) + + useNotificationPreferencesSpy.mockReturnValue({ + getAllPreferences: getAllPreferencesMock, + getChainPreferences: getChainPreferencesMock, + } as unknown as ReturnType<(typeof useNotificationPreferences)['useNotificationPreferences']>) + + selectNotificationsSpy.mockReturnValue({} as ReturnType<(typeof notificationsSlice)['selectNotifications']>) + useAppDispatchSpy.mockReturnValue(dispatchMock) + useNotificationsTokenVersionSpy.mockReturnValue(notificationsTokenVersionMock) + useIsNotificationsRenewalEnabledSpy.mockReturnValue(true) + }) + + it('if the Notifications Renewal feature flag is disabled, should return the correct values', () => { + useIsNotificationsRenewalEnabledSpy.mockReturnValue(false) + + const { result } = renderHook(() => useNotificationsRenewal()) + + expect(result.current.safesForRenewal).toBeUndefined() + expect(result.current.numberChainsForRenewal).toBe(0) + expect(result.current.numberSafesForRenewal).toBe(0) + expect(result.current.renewNotifications).toBeInstanceOf(Function) + expect(result.current.needsRenewal).toBe(false) + + expect(useSafeInfoSpy).toHaveBeenCalledTimes(1) + expect(useNotificationRegistrationsSpy).toHaveBeenCalledTimes(1) + expect(useNotificationPreferencesSpy).toHaveBeenCalledTimes(1) + expect(useNotificationsTokenVersionSpy).toHaveBeenCalledTimes(1) + expect(useIsNotificationsRenewalEnabledSpy).toHaveBeenCalledTimes(1) + + expect(getAllPreferencesMock).not.toHaveBeenCalled() + expect(getChainPreferencesMock).not.toHaveBeenCalled() + + expect(registerNotificationsMock).not.toHaveBeenCalled() + expect(registerNotificationsMock).not.toHaveBeenCalled() + }) + + describe('if a Safe is loaded', () => { + it('should return the correct values for the loaded Safe`s chain', () => { + const { result } = renderHook(() => useNotificationsRenewal()) + + expect(result.current.safesForRenewal).toStrictEqual({ [chainId1]: [safeAddress1, safeAddress3] }) + expect(result.current.numberChainsForRenewal).toBe(1) + expect(result.current.numberSafesForRenewal).toBe(2) + expect(result.current.renewNotifications).toBeInstanceOf(Function) + expect(result.current.needsRenewal).toBe(true) + + expect(getAllPreferencesMock).not.toHaveBeenCalled() + expect(getChainPreferencesMock).toHaveBeenCalledTimes(1) + expect(getChainPreferencesMock).toHaveBeenCalledWith(chainId1) + }) + + it('should return correct values if no Safe for the current chain needs renewal', () => { + useNotificationsTokenVersionSpy.mockReturnValue({ + safeTokenVersion: V2, + allTokenVersions: { [chainId1]: { [safeAddress1]: V2, [safeAddress2]: V2, [safeAddress3]: V2 } }, + setTokenVersion: jest.fn(), + }) + + const { result } = renderHook(() => useNotificationsRenewal()) + + expect(result.current.safesForRenewal).toBeUndefined() + expect(result.current.numberChainsForRenewal).toBe(0) + expect(result.current.numberSafesForRenewal).toBe(0) + expect(result.current.renewNotifications).toBeInstanceOf(Function) + expect(result.current.needsRenewal).toBe(false) + + expect(getAllPreferencesMock).not.toHaveBeenCalled() + expect(getChainPreferencesMock).toHaveBeenCalledTimes(1) + expect(getChainPreferencesMock).toHaveBeenCalledWith(chainId1) + }) + + it('should return correct values if current Safe is already renewed and no other Safe on current chain has notifications enabled', () => { + useNotificationsTokenVersionSpy.mockReturnValue({ + safeTokenVersion: V2, + allTokenVersions: { [chainId1]: { [safeAddress1]: V2 } }, + setTokenVersion: jest.fn(), + }) + + getChainPreferencesMock.mockReturnValue([preferencesMock[`${chainId1}:${safeAddress1}`]]) + + const { result } = renderHook(() => useNotificationsRenewal()) + + expect(result.current.safesForRenewal).toBeUndefined() + expect(result.current.numberChainsForRenewal).toBe(0) + expect(result.current.numberSafesForRenewal).toBe(0) + expect(result.current.renewNotifications).toBeInstanceOf(Function) + expect(result.current.needsRenewal).toBe(false) + + expect(getAllPreferencesMock).not.toHaveBeenCalled() + expect(getChainPreferencesMock).toHaveBeenCalledTimes(1) + expect(getChainPreferencesMock).toHaveBeenCalledWith(chainId1) + }) + }) + + describe('if NO Safe is loaded', () => { + beforeEach(() => { + useSafeInfoSpy.mockReturnValue({ + safe: { + chainId: undefined, + address: { value: undefined }, + }, + safeLoaded: false, + } as unknown as ReturnType) + }) + + it('should return the correct values for all the Safes with preferences', () => { + const { result } = renderHook(() => useNotificationsRenewal()) + + expect(result.current.safesForRenewal).toStrictEqual({ + [chainId1]: [safeAddress1, safeAddress3], + [chainId2]: [safeAddress1], + }) + expect(result.current.numberChainsForRenewal).toBe(2) + expect(result.current.numberSafesForRenewal).toBe(3) + expect(result.current.renewNotifications).toBeInstanceOf(Function) + expect(result.current.needsRenewal).toBe(true) + + expect(getChainPreferencesMock).not.toHaveBeenCalled() + expect(getAllPreferencesMock).toHaveBeenCalledTimes(1) + }) + + it('should return correct values if no notification preferences are stored', () => { + getAllPreferencesMock.mockReturnValue(undefined) + + const { result } = renderHook(() => useNotificationsRenewal()) + + expect(result.current.safesForRenewal).toBeUndefined() + expect(result.current.numberChainsForRenewal).toBe(0) + expect(result.current.numberSafesForRenewal).toBe(0) + expect(result.current.renewNotifications).toBeInstanceOf(Function) + expect(result.current.needsRenewal).toBe(false) + + expect(getChainPreferencesMock).not.toHaveBeenCalled() + expect(getAllPreferencesMock).toHaveBeenCalledTimes(1) + }) + + it('should return correct values if no Safe needs renewal', () => { + useNotificationsTokenVersionSpy.mockReturnValue({ + safeTokenVersion: V2, + allTokenVersions: { + [chainId1]: { [safeAddress1]: V2, [safeAddress2]: V2, [safeAddress3]: V2 }, + [chainId2]: { [safeAddress1]: V2 }, + }, + setTokenVersion: jest.fn(), + }) + + const { result } = renderHook(() => useNotificationsRenewal()) + + expect(result.current.safesForRenewal).toBeUndefined() + expect(result.current.numberChainsForRenewal).toBe(0) + expect(result.current.numberSafesForRenewal).toBe(0) + expect(result.current.renewNotifications).toBeInstanceOf(Function) + expect(result.current.needsRenewal).toBe(false) + + expect(getChainPreferencesMock).not.toHaveBeenCalled() + expect(getAllPreferencesMock).toHaveBeenCalledTimes(1) + }) + }) + + describe('renewNotifications', () => { + it('should call `registerNotifications` with the Safes that need to be renewed', async () => { + const { result } = renderHook(() => useNotificationsRenewal()) + + await result.current.renewNotifications() + + expect(registerNotificationsMock).toHaveBeenCalledTimes(1) + expect(registerNotificationsMock).toHaveBeenCalledWith(result.current.safesForRenewal) + }) + + it('should show an error notification if `registerNotifications` call throws', async () => { + const notificationMock = { + message: 'Something went wrong', + groupKey: RENEWAL_NOTIFICATION_KEY, + } + showNotificationSpy.mockReturnValue( + notificationMock as unknown as ReturnType<(typeof notificationsSlice)['showNotification']>, + ) + registerNotificationsMock.mockRejectedValueOnce(new Error('Failed to renew notifications')) + + const { result } = renderHook(() => useNotificationsRenewal()) + + await result.current.renewNotifications() + + expect(registerNotificationsMock).toHaveBeenCalledTimes(1) + expect(registerNotificationsMock).toHaveBeenCalledWith(result.current.safesForRenewal) + + await waitFor(async () => { + expect(dispatchMock).toHaveBeenCalledTimes(1) + expect(dispatchMock).toHaveBeenCalledWith(notificationMock) + + expect(showNotificationSpy).toHaveBeenCalledTimes(1) + expect(showNotificationSpy).toHaveBeenCalledWith({ + message: 'Failed to renew notifications', + variant: 'error', + detailedMessage: 'Failed to renew notifications', + groupKey: RENEWAL_NOTIFICATION_KEY, + }) + }) + }) + + it('should NOT call `registerNotifications` if no Safes need to be renewed', async () => { + getChainPreferencesMock.mockReturnValue([]) + + const { result } = renderHook(() => useNotificationsRenewal()) + + await result.current.renewNotifications() + + expect(registerNotificationsMock).not.toHaveBeenCalled() + }) + }) +}) 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 new file mode 100644 index 000000000..cf8cd2fa5 --- /dev/null +++ b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationsTokenVersion.test.ts @@ -0,0 +1,150 @@ +import { toBeHex } from 'ethers' +import { NOTIFICATIONS_TOKEN_VERSION_KEY, useNotificationsTokenVersion } from '../useNotificationsTokenVersion' +import * as useChains from '@/hooks/useChains' +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' + +const { V1, V2 } = NotificationsTokenVersion + +describe('useNotificationsTokenVersion', () => { + const chainId1 = '123' + const chainId2 = '234' + + const safeAddress1 = toBeHex('0x1', 20) + const safeAddress2 = toBeHex('0x2', 20) + + const localStorageMock = { [chainId1]: { [safeAddress1]: V1 }, [chainId2]: { [safeAddress2]: V2 } } + const setLocalStorageMock = jest.fn() + + const useHasFeatureSpy = jest.spyOn(useChains, 'useHasFeature') + const localStorageSpy = jest.spyOn(localStorage, 'default') + const useSafeInfoSpy = jest.spyOn(useSafeInfoHook, 'default') + + afterEach(() => { + jest.clearAllMocks() + }) + + beforeEach(() => { + useHasFeatureSpy.mockReturnValue(true) + localStorageSpy.mockReturnValue([localStorageMock, setLocalStorageMock]) + + useSafeInfoSpy.mockReturnValue({ + safe: { + chainId: chainId1, + address: { value: safeAddress1 }, + }, + safeLoaded: true, + } as unknown as ReturnType) + }) + + it("should return the current loaded Safe's token version", () => { + const { result } = renderHook(() => useNotificationsTokenVersion()) + + expect(result.current.safeTokenVersion).toBe(V1) + expect(result.current.allTokenVersions).toBe(localStorageMock) + expect(result.current.setTokenVersion).toBeInstanceOf(Function) + + expect(useHasFeatureSpy).toHaveBeenCalledTimes(1) + expect(useHasFeatureSpy).toHaveBeenCalledWith(FEATURES.RENEW_NOTIFICATIONS_TOKEN) + + expect(localStorageSpy).toHaveBeenCalledTimes(1) + expect(localStorageSpy).toHaveBeenCalledWith(NOTIFICATIONS_TOKEN_VERSION_KEY) + + expect(useSafeInfoSpy).toHaveBeenCalledTimes(1) + expect(useSafeInfoSpy).toHaveBeenCalledWith() + }) + + it('should return undefined `safeTokenVersion` if no Safe is loaded', () => { + useSafeInfoSpy.mockReturnValue({ + safe: { + chainId: undefined, + address: { value: undefined }, + }, + safeLoaded: false, + } as unknown as ReturnType) + + const { result } = renderHook(() => useNotificationsTokenVersion()) + + expect(result.current.safeTokenVersion).toBe(undefined) + expect(result.current.allTokenVersions).toBe(localStorageMock) + expect(result.current.setTokenVersion).toBeInstanceOf(Function) + }) + + it('should return undefined `allTokenVersions` if no token versions are stored', () => { + localStorageSpy.mockReturnValue([undefined, setLocalStorageMock]) + + const { result } = renderHook(() => useNotificationsTokenVersion()) + + expect(result.current.safeTokenVersion).toBe(undefined) + expect(result.current.allTokenVersions).toBe(undefined) + expect(result.current.setTokenVersion).toBeInstanceOf(Function) + }) + + it('should return undefined for `safeTokenVersion` if notifications renewal is not enabled', () => { + useHasFeatureSpy.mockReturnValue(false) + + const { result } = renderHook(() => useNotificationsTokenVersion()) + + expect(result.current.safeTokenVersion).toBe(undefined) + expect(result.current.allTokenVersions).toBe(undefined) + expect(result.current.setTokenVersion).toBeInstanceOf(Function) + }) + + describe('setTokenVersion', () => { + beforeEach(() => {}) + + it('should update the token version for the current loaded Safe', () => { + const { result } = renderHook(() => useNotificationsTokenVersion()) + + result.current.setTokenVersion(V2) + + expect(setLocalStorageMock).toHaveBeenCalledTimes(1) + expect(setLocalStorageMock).toHaveBeenCalledWith({ ...localStorageMock, [chainId1]: { [safeAddress1]: V2 } }) + }) + + it('should update the token version for the provided Safes', () => { + const chainId3 = '987' + const safesToUpdate = { [chainId2]: [safeAddress2], [chainId3]: [safeAddress1] } + + const { result } = renderHook(() => useNotificationsTokenVersion()) + + result.current.setTokenVersion(V2, safesToUpdate) + + expect(setLocalStorageMock).toHaveBeenCalledTimes(1) + expect(setLocalStorageMock).toHaveBeenCalledWith({ + ...localStorageMock, + [chainId2]: { [safeAddress2]: V2 }, + [chainId3]: { [safeAddress1]: V2 }, + }) + }) + + it('should not update the token version if notifications renewal is not enabled', () => { + useHasFeatureSpy.mockReturnValue(false) + + const { result } = renderHook(() => useNotificationsTokenVersion()) + + result.current.setTokenVersion(V2) + + expect(setLocalStorageMock).not.toHaveBeenCalled() + }) + + it('should not update the token version if no Safes are provided and no Safe is loaded', () => { + useSafeInfoSpy.mockReturnValue({ + safe: { + chainId: undefined, + address: { value: undefined }, + }, + safeLoaded: false, + } as unknown as ReturnType) + + const { result } = renderHook(() => useNotificationsTokenVersion()) + + result.current.setTokenVersion(V2) + + expect(setLocalStorageMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useShowNotificationsRenewalMessage.test.ts b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useShowNotificationsRenewalMessage.test.ts new file mode 100644 index 000000000..3c12ea97b --- /dev/null +++ b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useShowNotificationsRenewalMessage.test.ts @@ -0,0 +1,195 @@ +import { toBeHex } from 'ethers' +import { useShowNotificationsRenewalMessage } from '../useShowNotificationsRenewalMessage' +import * as useSafeInfoHook from '@/hooks/useSafeInfo' +import * as useWalletHook from '@/hooks/wallets/useWallet' +import * as store from '@/store' +import * as useNotificationsTokenVersion from '../useNotificationsTokenVersion' +import * as useNotificationPreferences from '../useNotificationPreferences' +import * as useNotificationsRenewal from '../useNotificationsRenewal' +import * as notificationsSlice from '@/store/notificationsSlice' +import * as useIsWrongChain from '@/hooks/useIsWrongChain' +import { NotificationsTokenVersion } from '@/services/push-notifications/preferences' +import { renderHook, waitFor } from '@testing-library/react' +import { RENEWAL_MESSAGE, RENEWAL_NOTIFICATION_KEY } from '../../constants' + +const { V1, V2 } = NotificationsTokenVersion + +describe('useShowNotificationsRenewalMessage', () => { + const chainId = '123' + + const safeAddress1 = toBeHex('0x1', 20) + const safeAddress2 = toBeHex('0x2', 20) + const safeAddress3 = toBeHex('0x3', 20) + + const useSafeInfoSpy = jest.spyOn(useSafeInfoHook, 'default') + const useWalletSpy = jest.spyOn(useWalletHook, 'default') + const useAppDispatchSpy = jest.spyOn(store, 'useAppDispatch') + const useAppSelectorSpy = jest.spyOn(store, 'useAppSelector') + const useNotificationsTokenVersionSpy = jest.spyOn(useNotificationsTokenVersion, 'useNotificationsTokenVersion') + const useNotificationPreferencesSpy = jest.spyOn(useNotificationPreferences, 'useNotificationPreferences') + const useNotificationsRenewalSpy = jest.spyOn(useNotificationsRenewal, 'useNotificationsRenewal') + const useIsNotificationsRenewalEnabledSpy = jest.spyOn( + useNotificationsTokenVersion, + 'useIsNotificationsRenewalEnabled', + ) + const useIsWrongChainSpy = jest.spyOn(useIsWrongChain, 'default') + const showNotificationSpy = jest.spyOn(notificationsSlice, 'showNotification') + const selectNotificationsSpy = jest.spyOn(notificationsSlice, 'selectNotifications') + + const dispatchMock = jest.fn() + const renewNotificationsMock = jest.fn() + const safePreferencesMock = useNotificationPreferences.DEFAULT_NOTIFICATION_PREFERENCES + const getPreferencesMock = jest.fn().mockReturnValue(safePreferencesMock) + + const notificationMock = { + message: 'Sign this message to renew your notifications', + groupKey: `${RENEWAL_NOTIFICATION_KEY}-${chainId}-${safeAddress1}`, + } + const notificationsMock = [{ message: 'Hello world', groupKey: 'helloWorld' }] + const notificationsTokenVersionMock = { + safeTokenVersion: undefined, + allTokenVersions: { [chainId]: { [safeAddress2]: V2, [safeAddress3]: V1 } }, + setTokenVersion: jest.fn(), + } + + afterEach(() => { + jest.clearAllMocks() + }) + + beforeEach(() => { + useSafeInfoSpy.mockReturnValue({ + safe: { + chainId, + address: { value: safeAddress1 }, + }, + safeLoaded: true, + } as unknown as ReturnType) + + useNotificationPreferencesSpy.mockReturnValue({ + getPreferences: getPreferencesMock, + } as unknown as ReturnType<(typeof useNotificationPreferences)['useNotificationPreferences']>) + + useNotificationsRenewalSpy.mockReturnValue({ + renewNotifications: renewNotificationsMock, + } as unknown as ReturnType<(typeof useNotificationsRenewal)['useNotificationsRenewal']>) + + selectNotificationsSpy.mockReturnValue({} as ReturnType<(typeof notificationsSlice)['selectNotifications']>) + useWalletSpy.mockReturnValue({} as ReturnType) + useAppDispatchSpy.mockReturnValue(dispatchMock) + useAppSelectorSpy.mockReturnValue(notificationsMock) + useNotificationsTokenVersionSpy.mockReturnValue(notificationsTokenVersionMock) + useIsWrongChainSpy.mockReturnValue(false) + useIsNotificationsRenewalEnabledSpy.mockReturnValue(true) + }) + + it('should show the renewal notification if needed + set the token version to V1', async () => { + renderHook(() => useShowNotificationsRenewalMessage()) + + expect(useSafeInfoSpy).toHaveBeenCalledTimes(1) + expect(useNotificationPreferencesSpy).toHaveBeenCalledTimes(1) + expect(getPreferencesMock).toHaveBeenCalledTimes(1) + expect(getPreferencesMock).toHaveBeenCalledWith(chainId, safeAddress1) + expect(useWalletSpy).toHaveBeenCalledTimes(1) + expect(useAppDispatchSpy).toHaveBeenCalledTimes(1) + expect(useIsWrongChainSpy).toHaveBeenCalledTimes(1) + expect(useIsNotificationsRenewalEnabledSpy).toHaveBeenCalledTimes(1) + expect(useAppSelectorSpy).toHaveBeenCalledTimes(1) + expect(useAppSelectorSpy).toHaveBeenCalledWith(notificationsSlice.selectNotifications) + expect(useNotificationsTokenVersionSpy).toHaveBeenCalledTimes(1) + expect(useNotificationsRenewalSpy).toHaveBeenCalledTimes(1) + + await waitFor(async () => { + expect(showNotificationSpy).toHaveBeenCalledTimes(1) + expect(showNotificationSpy).toHaveBeenCalledWith({ + message: RENEWAL_MESSAGE, + variant: 'warning', + groupKey: `${RENEWAL_NOTIFICATION_KEY}-${chainId}-${safeAddress1}`, + link: { + onClick: expect.any(Function), + title: 'Sign', + }, + }) + + expect(dispatchMock).toHaveBeenCalledTimes(1) + + expect(notificationsTokenVersionMock.setTokenVersion).toHaveBeenCalledTimes(1) + expect(notificationsTokenVersionMock.setTokenVersion).toHaveBeenCalledWith(V1) + }) + }) + + it('should call the `renewNotifications` function if the message link is clicked', async () => { + const simulateLinkClick = ({ link }: Parameters<(typeof notificationsSlice)['showNotification']>[0]) => { + if (link && 'onClick' in link) { + link.onClick() + } + } + + renderHook(() => useShowNotificationsRenewalMessage()) + + await waitFor(async () => { + expect(showNotificationSpy).toHaveBeenCalledTimes(1) + expect(dispatchMock).toHaveBeenCalledTimes(1) + expect(renewNotificationsMock).not.toHaveBeenCalled() + }) + + simulateLinkClick(showNotificationSpy.mock.calls[0][0]) + + expect(renewNotificationsMock).toHaveBeenCalledTimes(1) + }) + + describe('should NOT show the renewal notification', () => { + const expectToNotShowNotification = async ( + renderHookFn: () => ReturnType, + ) => { + renderHook(renderHookFn) + + await waitFor(async () => { + expect(showNotificationSpy).not.toHaveBeenCalled() + expect(dispatchMock).not.toHaveBeenCalled() + expect(notificationsTokenVersionMock.setTokenVersion).not.toHaveBeenCalled() + expect(renewNotificationsMock).not.toHaveBeenCalled() + }) + } + + it('if no signer is connected', async () => { + useWalletSpy.mockReturnValueOnce(null) + await expectToNotShowNotification(() => useShowNotificationsRenewalMessage()) + }) + + it('if there are no preferences for the Safe', async () => { + getPreferencesMock.mockReturnValueOnce(null) + await expectToNotShowNotification(() => useShowNotificationsRenewalMessage()) + }) + + it('if no Safe is loaded', async () => { + useSafeInfoSpy.mockReturnValueOnce({ + safe: { + chainId: undefined, + address: { value: undefined }, + }, + safeLoaded: false, + } as unknown as ReturnType) + await expectToNotShowNotification(() => useShowNotificationsRenewalMessage()) + }) + + it('if the user is on the wrong chain', async () => { + useIsWrongChainSpy.mockReturnValueOnce(true) + await expectToNotShowNotification(() => useShowNotificationsRenewalMessage()) + }) + + it('if the Safe`s token version is set', async () => { + useNotificationsTokenVersionSpy.mockReturnValueOnce({ ...notificationsTokenVersionMock, safeTokenVersion: V1 }) + await expectToNotShowNotification(() => useShowNotificationsRenewalMessage()) + }) + + it('if there already is a notification message', async () => { + useAppSelectorSpy.mockReturnValueOnce([notificationMock]) + await expectToNotShowNotification(() => useShowNotificationsRenewalMessage()) + }) + + it('if notifications renewal feature is not enabled', async () => { + useIsNotificationsRenewalEnabledSpy.mockReturnValueOnce(false) + await expectToNotShowNotification(() => useShowNotificationsRenewalMessage()) + }) + }) +}) diff --git a/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts similarity index 95% rename from src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts rename to apps/web/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts index 4cc26e7cc..68bd489fb 100644 --- a/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts @@ -57,6 +57,7 @@ export const useNotificationPreferences = (): { [PushNotificationPrefsKey, PushNotificationPreferences[PushNotificationPrefsKey]][] > _deleteManyPreferenceKeys: (keysToDelete: PushNotificationPrefsKey[]) => void + getChainPreferences: (chainId: string) => PushNotificationPreferences[PushNotificationPrefsKey][] } => { // State const uuid = useUuid() @@ -72,6 +73,14 @@ export const useNotificationPreferences = (): { return preferences }, [preferences]) + // Get list of preferences for specified chain + const getChainPreferences = useCallback( + (chainId: string) => { + return Object.values(preferences || {}).filter((pref) => chainId === pref.chainId) + }, + [preferences], + ) + // idb-keyval stores const uuidStore = useMemo(() => { if (typeof indexedDB !== 'undefined') { @@ -253,5 +262,6 @@ export const useNotificationPreferences = (): { deleteAllChainPreferences, _getAllPreferenceEntries, _deleteManyPreferenceKeys, + getChainPreferences, } } diff --git a/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts similarity index 87% rename from src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts rename to apps/web/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts index 7bde23e3d..16528df21 100644 --- a/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts @@ -1,4 +1,5 @@ import { registerDevice, unregisterDevice, unregisterSafe } from '@safe-global/safe-gateway-typescript-sdk' +import isEmpty from 'lodash/isEmpty' import { useAppDispatch } from '@/store' import { showNotification } from '@/store/notificationsSlice' @@ -10,6 +11,8 @@ import { logError } from '@/services/exceptions' import ErrorCodes from '@/services/exceptions/ErrorCodes' import useWallet from '@/hooks/wallets/useWallet' import type { NotifiableSafes } from '../logic' +import { NotificationsTokenVersion } from '@/services/push-notifications/preferences' +import { useNotificationsTokenVersion } from './useNotificationsTokenVersion' const registrationFlow = async (registrationFn: Promise, callback: () => void): Promise => { let success = false @@ -19,7 +22,7 @@ const registrationFlow = async (registrationFn: Promise, callback: () = // Gateway will return 200 with an empty payload if the device was (un-)registered successfully // @see https://github.com/safe-global/safe-client-gateway-nest/blob/27b6b3846b4ecbf938cdf5d0595ca464c10e556b/src/routes/notifications/notifications.service.ts#L29 - success = response == null + success = isEmpty(response) } catch (e) { logError(ErrorCodes._633, e) } @@ -39,6 +42,7 @@ export const useNotificationRegistrations = (): { const dispatch = useAppDispatch() const wallet = useWallet() + const { setTokenVersion } = useNotificationsTokenVersion() const { uuid, createPreferences, deletePreferences, deleteAllChainPreferences } = useNotificationPreferences() const registerNotifications = async (safesToRegister: NotifiableSafes) => { @@ -64,6 +68,9 @@ export const useNotificationRegistrations = (): { 0, ) + // Set the token version to V2 to indicate that the user has registered their token for the new notification service + setTokenVersion(NotificationsTokenVersion.V2, safesToRegister) + trackEvent({ ...PUSH_NOTIFICATION_EVENTS.REGISTER_SAFES, label: totalRegistered, diff --git a/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts similarity index 100% rename from src/components/settings/PushNotifications/hooks/useNotificationTracking.ts rename to apps/web/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts diff --git a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsRenewal.ts b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsRenewal.ts new file mode 100644 index 000000000..9fec91351 --- /dev/null +++ b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsRenewal.ts @@ -0,0 +1,126 @@ +import useSafeInfo from '@/hooks/useSafeInfo' +import { useNotificationPreferences } from './useNotificationPreferences' +import { useNotificationRegistrations } from './useNotificationRegistrations' +import { useCallback, useMemo } from 'react' +import { NotificationsTokenVersion } from '@/services/push-notifications/preferences' +import { useIsNotificationsRenewalEnabled, useNotificationsTokenVersion } from './useNotificationsTokenVersion' +import type { NotifiableSafes } from '../logic' +import { flatten, isEmpty } from 'lodash' +import { useAppDispatch } from '@/store' +import { showNotification } from '@/store/notificationsSlice' +import { RENEWAL_NOTIFICATION_KEY } from '../constants' + +/** + * Hook to manage the renewal of notifications + * @param shouldShowRenewalNotification a boolean to determine if the renewal notification should be shown + * @returns an object containing the safes for renewal, the number of chains for renewal, the number of safes for renewal, + * the renewNotifications function and a boolean indicating if a renewal is needed + */ +export const useNotificationsRenewal = () => { + const { safe, safeLoaded } = useSafeInfo() + const { registerNotifications } = useNotificationRegistrations() + const { getAllPreferences, getChainPreferences } = useNotificationPreferences() + const { allTokenVersions } = useNotificationsTokenVersion() + const isNotificationsRenewalEnabled = useIsNotificationsRenewalEnabled() + const dispatch = useAppDispatch() + + /** + * Function to check if a renewal is needed for a specific Safe based on the locally stored token version + * @param chainId the chainId of the Safe + * @param safeAddress the address of the Safe + * @returns a boolean indicating if a renewal is needed + */ + const checkIsRenewalNeeded = useCallback( + (chainId: string, safeAddress: string) => + allTokenVersions?.[chainId]?.[safeAddress] !== NotificationsTokenVersion.V2, + [allTokenVersions], + ) + + // Safes that need to be renewed based on the locally stored token version. If a Safe is loaded, only the relevant + // Safes for the corresponding chain are returned. Otherwise, all Safes that need to be renewed are returned. + const safesForRenewal = useMemo(() => { + if (!isNotificationsRenewalEnabled) { + // Notifications renewal feature flag is not enabled + return undefined + } + + if (safeLoaded) { + // If the Safe is loaded, only the Safes for the corresponding chain are checked + const chainPreferences = getChainPreferences(safe.chainId) + + // Determine the Safes that need to be renewed for the loaded Safe's chain + const safeAddressesForRenewal = chainPreferences + .map((pref) => pref.safeAddress) + .filter((address) => checkIsRenewalNeeded(safe.chainId, address)) + + if (safeAddressesForRenewal.length === 0) { + return undefined + } + + return { [safe.chainId]: safeAddressesForRenewal } + } + + const allPreferences = getAllPreferences() + + if (!allPreferences) { + return undefined + } + + // Determine the Safes that need to be renewed for all chains + const safesForRenewal = Object.values(allPreferences).reduce( + (acc, { chainId, safeAddress }) => + checkIsRenewalNeeded(chainId, safeAddress) + ? { ...acc, [chainId]: [...(acc[chainId] || []), safeAddress] } + : acc, + {}, + ) + + return isEmpty(safesForRenewal) ? undefined : safesForRenewal + }, [ + safeLoaded, + safe.chainId, + getAllPreferences, + getChainPreferences, + checkIsRenewalNeeded, + isNotificationsRenewalEnabled, + ]) + + // Number of Safes that need to be renewed for notifications + const numberSafesForRenewal = useMemo(() => { + return safesForRenewal ? flatten(Object.values(safesForRenewal)).length : 0 + }, [safesForRenewal]) + + // Number of chains with Safes that need to be renewed for notifications + const numberChainsForRenewal = useMemo(() => { + return safesForRenewal ? Object.values(safesForRenewal).filter((addresses) => addresses.length > 0).length : 0 + }, [safesForRenewal]) + + // Boolean indicating if a notifications renewal is needed for any Safe + const needsRenewal = useMemo(() => { + if (safeLoaded) { + return safesForRenewal?.[safe.chainId]?.includes(safe.address.value) || false + } + return numberSafesForRenewal > 0 + }, [numberSafesForRenewal, safe.address.value, safe.chainId, safeLoaded, safesForRenewal]) + + /** + * Function to renew the notifications for the Safes that need it + * @returns a Promise that resolves when the notifications have been renewed + */ + const renewNotifications = useCallback(async () => { + if (safesForRenewal) { + return registerNotifications(safesForRenewal).catch((err) => { + dispatch( + showNotification({ + message: 'Failed to renew notifications', + variant: 'error', + detailedMessage: err.message, + groupKey: RENEWAL_NOTIFICATION_KEY, + }), + ) + }) + } + }, [safesForRenewal, dispatch, registerNotifications]) + + return { safesForRenewal, numberChainsForRenewal, numberSafesForRenewal, renewNotifications, needsRenewal } +} diff --git a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsTokenVersion.ts b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsTokenVersion.ts new file mode 100644 index 000000000..1e605d133 --- /dev/null +++ b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsTokenVersion.ts @@ -0,0 +1,86 @@ +import useSafeInfo from '@/hooks/useSafeInfo' +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' + +export const NOTIFICATIONS_TOKEN_VERSION_KEY = 'notificationsTokenVersion' + +type TokenVersionStore = { + [chainId: string]: { + [safeAddress: string]: NotificationsTokenVersion | undefined + } +} + +export const useIsNotificationsRenewalEnabled = () => { + return useHasFeature(FEATURES.RENEW_NOTIFICATIONS_TOKEN) +} + +/** + * Hook to get and update the token versions for the notifications in the local storage. + * @returns an object with the token version for the current loaded Safe, all token versions stored in the local storage, + * and a function to update the token version. + */ +export const useNotificationsTokenVersion = () => { + const isNotificationsRenewalEnabled = useIsNotificationsRenewalEnabled() + const { safe, safeLoaded } = useSafeInfo() + const safeAddress = safe.address.value + + // Token versions are stored in local storage + const [allTokenVersions, setAllTokenVersionsStore] = useLocalStorage( + NOTIFICATIONS_TOKEN_VERSION_KEY, + ) + + /** + * Updates the token version for the specified Safes in the local storage. + * @param tokenVersion new token version + * @param safes object with Safes to update the token version. If not provided, the token version will be + * updated for the current loaded Safe only. + */ + const setTokenVersion = ( + tokenVersion: NotificationsTokenVersion | undefined, + safes: NotifiableSafes | undefined = safeLoaded ? { [safe.chainId]: [safeAddress] } : undefined, + ) => { + const currentTokenVersionStore = allTokenVersions || {} + + if (!isNotificationsRenewalEnabled) { + // Notifications renewal is not enabled, nothing to update + return + } + + if (!safes) { + // No Safes provided and no Safe loaded, nothing to update + return + } + + // Update the token version for the provided Safes + const newTokenVersionStore = Object.keys(safes).reduce( + (acc, chainId) => ({ + ...acc, + [chainId]: { + ...(acc[chainId] || {}), + ...Object.fromEntries(safes[chainId].map((safeAddress) => [safeAddress, tokenVersion])), + }, + }), + currentTokenVersionStore, + ) + + setAllTokenVersionsStore(newTokenVersionStore) + } + + if (!isNotificationsRenewalEnabled) { + // Notifications renewal is not enabled, no token versions stored + return { safeTokenVersion: undefined, allTokenVersions: undefined, setTokenVersion } + } + + if (!allTokenVersions) { + // No token versions stored + return { safeTokenVersion: undefined, allTokenVersions, setTokenVersion } + } + + // Get the stored token version for the current loaded Safe + const safeTokenVersion = safeLoaded ? allTokenVersions[safe.chainId]?.[safeAddress] : undefined + + return { safeTokenVersion, allTokenVersions, setTokenVersion } +} diff --git a/apps/web/src/components/settings/PushNotifications/hooks/useShowNotificationsRenewalMessage.ts b/apps/web/src/components/settings/PushNotifications/hooks/useShowNotificationsRenewalMessage.ts new file mode 100644 index 000000000..9d3340ca4 --- /dev/null +++ b/apps/web/src/components/settings/PushNotifications/hooks/useShowNotificationsRenewalMessage.ts @@ -0,0 +1,77 @@ +import useSafeInfo from '@/hooks/useSafeInfo' +import { selectNotifications, showNotification } from '@/store/notificationsSlice' +import { useEffect, useMemo } from 'react' +import { useNotificationPreferences } from './useNotificationPreferences' +import useWallet from '@/hooks/wallets/useWallet' +import { useAppDispatch, useAppSelector } from '@/store' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import { useIsNotificationsRenewalEnabled, useNotificationsTokenVersion } from './useNotificationsTokenVersion' +import { useNotificationsRenewal } from './useNotificationsRenewal' +import { NotificationsTokenVersion } from '@/services/push-notifications/preferences' +import { RENEWAL_MESSAGE, RENEWAL_NOTIFICATION_KEY } from '../constants' + +/** + * Hook to show a notification to renew the notifications token if needed. + */ +export const useShowNotificationsRenewalMessage = () => { + const { safe, safeLoaded } = useSafeInfo() + const { getPreferences } = useNotificationPreferences() + const preferences = getPreferences(safe.chainId, safe.address.value) + const wallet = useWallet() + const dispatch = useAppDispatch() + const isWrongChain = useIsWrongChain() + const { safeTokenVersion, setTokenVersion } = useNotificationsTokenVersion() + const isNotificationsRenewalEnabled = useIsNotificationsRenewalEnabled() + const notifications = useAppSelector(selectNotifications) + const { renewNotifications } = useNotificationsRenewal() + + const notificationGroupKey = useMemo( + () => `${RENEWAL_NOTIFICATION_KEY}-${safe.chainId}-${safe.address.value}`, + [safe.chainId, safe.address.value], + ) + + // Check if a renewal notification is already present + const hasNotificationMessage = useMemo( + () => notifications.some((notification) => notification.groupKey === notificationGroupKey), + [notifications, notificationGroupKey], + ) + + useEffect(() => { + if ( + !!wallet && + !!preferences && + safeLoaded && + !isWrongChain && + !safeTokenVersion && + !hasNotificationMessage && + isNotificationsRenewalEnabled + ) { + dispatch( + showNotification({ + message: RENEWAL_MESSAGE, + variant: 'warning', + groupKey: notificationGroupKey, + link: { + onClick: renewNotifications, + title: 'Sign', + }, + }), + ) + + // Set the token version to V1 to avoid showing the notification again + setTokenVersion(NotificationsTokenVersion.V1) + } + }, [ + dispatch, + renewNotifications, + preferences, + safeLoaded, + notificationGroupKey, + safeTokenVersion, + isWrongChain, + hasNotificationMessage, + wallet, + setTokenVersion, + isNotificationsRenewalEnabled, + ]) +} diff --git a/apps/web/src/components/settings/PushNotifications/index.tsx b/apps/web/src/components/settings/PushNotifications/index.tsx new file mode 100644 index 000000000..902c59485 --- /dev/null +++ b/apps/web/src/components/settings/PushNotifications/index.tsx @@ -0,0 +1,296 @@ +import { + Grid, + Paper, + Typography, + Checkbox, + FormControlLabel, + FormGroup, + Alert, + Switch, + Divider, + Link as MuiLink, + useMediaQuery, + useTheme, +} from '@mui/material' +import Link from 'next/link' +import { useState } from 'react' +import type { ReactElement } from 'react' + +import useSafeInfo from '@/hooks/useSafeInfo' +import EthHashInfo from '@/components/common/EthHashInfo' +import { WebhookType } from '@/service-workers/firebase-messaging/webhook-types' +import { useNotificationRegistrations } from './hooks/useNotificationRegistrations' +import { useNotificationPreferences } from './hooks/useNotificationPreferences' +import { GlobalPushNotifications } from './GlobalPushNotifications' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { HelpCenterArticle, 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 css from './styles.module.css' +import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import NotificationRenewal from '@/components/notification-center/NotificationRenewal' + +export const PushNotifications = (): ReactElement => { + const { safe, safeLoaded } = useSafeInfo() + const isOwner = useIsSafeOwner() + const isMac = useIsMac() + const [isRegistering, setIsRegistering] = useState(false) + const [isUpdatingIndexedDb, setIsUpdatingIndexedDb] = useState(false) + const theme = useTheme() + const isLargeScreen = useMediaQuery(theme.breakpoints.up('lg')) + + const { updatePreferences, getPreferences, getAllPreferences } = useNotificationPreferences() + const { unregisterSafeNotifications, unregisterDeviceNotifications, registerNotifications } = + useNotificationRegistrations() + + const preferences = getPreferences(safe.chainId, safe.address.value) + + const setPreferences = (newPreferences: NonNullable>) => { + setIsUpdatingIndexedDb(true) + + updatePreferences(safe.chainId, safe.address.value, newPreferences) + + setIsUpdatingIndexedDb(false) + } + + const shouldShowMacHelper = isMac || IS_DEV + + const handleOnChange = async () => { + setIsRegistering(true) + + if (!preferences) { + await registerNotifications({ [safe.chainId]: [safe.address.value] }) + trackEvent(PUSH_NOTIFICATION_EVENTS.ENABLE_SAFE) + setIsRegistering(false) + return + } + + const allPreferences = getAllPreferences() + const totalRegisteredSafesOnChain = allPreferences + ? Object.values(allPreferences).filter(({ chainId }) => chainId === safe.chainId).length + : 0 + const shouldUnregisterDevice = totalRegisteredSafesOnChain === 1 + + if (shouldUnregisterDevice) { + await unregisterDeviceNotifications(safe.chainId) + } else { + await unregisterSafeNotifications(safe.chainId, safe.address.value) + } + + trackEvent(PUSH_NOTIFICATION_EVENTS.DISABLE_SAFE) + setIsRegistering(false) + } + + return ( + <> + + + + + Push notifications + + + + + + + + + Enable push notifications for {safeLoaded ? 'this Safe Account' : 'your Safe Accounts'} in your browser + with your signature. You will need to enable them again if you clear your browser cache. Learn more + about push notifications here + + + {shouldShowMacHelper && ( + + + For macOS users + + + Double-check that you have enabled your browser notifications under System Settings >{' '} + Notifications > Application Notifications (path may vary depending on OS version). + + + )} + + {safeLoaded ? ( + <> + + + +
+ + + {(isOk) => ( + } + label={preferences ? 'On' : 'Off'} + disabled={!isOk || isRegistering || !safe.deployed} + /> + )} + +
+ + + + Want to setup notifications for different or all Safe Accounts? You can do so in your{' '} + + global preferences + + . + + + + ) : ( + + )} +
+
+
+
+ {preferences && ( + + + + + Notification + + + + + + { + setPreferences({ + ...preferences, + [WebhookType.INCOMING_ETHER]: checked, + [WebhookType.INCOMING_TOKEN]: checked, + }) + + trackEvent({ ...PUSH_NOTIFICATION_EVENTS.TOGGLE_INCOMING_TXS, label: checked }) + }} + /> + } + label="Incoming transactions" + /> + + { + setPreferences({ + ...preferences, + [WebhookType.MODULE_TRANSACTION]: checked, + [WebhookType.EXECUTED_MULTISIG_TRANSACTION]: checked, + }) + + trackEvent({ ...PUSH_NOTIFICATION_EVENTS.TOGGLE_OUTGOING_TXS, label: checked }) + }} + /> + } + label="Outgoing transactions" + /> + + { + const updateConfirmationRequestPreferences = () => { + setPreferences({ + ...preferences, + [WebhookType.CONFIRMATION_REQUEST]: checked, + }) + + trackEvent({ ...PUSH_NOTIFICATION_EVENTS.TOGGLE_CONFIRMATION_REQUEST, label: checked }) + } + + if (checked) { + registerNotifications({ + [safe.chainId]: [safe.address.value], + }) + .then((registered) => { + if (registered) { + updateConfirmationRequestPreferences() + } + }) + .catch(() => null) + } else { + updateConfirmationRequestPreferences() + } + }} + /> + } + label={ + <> + Confirmation requests + {!preferences[WebhookType.CONFIRMATION_REQUEST] && ( + + {isOwner ? 'Requires your signature' : 'Only signers'} + + )} + + } + disabled={!isOwner || !preferences} + /> + + + + + )} + + ) +} diff --git a/src/components/settings/PushNotifications/logic.ts b/apps/web/src/components/settings/PushNotifications/logic.ts similarity index 100% rename from src/components/settings/PushNotifications/logic.ts rename to apps/web/src/components/settings/PushNotifications/logic.ts diff --git a/src/components/settings/PushNotifications/styles.module.css b/apps/web/src/components/settings/PushNotifications/styles.module.css similarity index 100% rename from src/components/settings/PushNotifications/styles.module.css rename to apps/web/src/components/settings/PushNotifications/styles.module.css diff --git a/apps/web/src/components/settings/RequiredConfirmations/index.tsx b/apps/web/src/components/settings/RequiredConfirmations/index.tsx new file mode 100644 index 000000000..88c17bb1a --- /dev/null +++ b/apps/web/src/components/settings/RequiredConfirmations/index.tsx @@ -0,0 +1,70 @@ +import { Box, Button, Grid, Typography } from '@mui/material' +import Track from '@/components/common/Track' +import { SETTINGS_EVENTS } from '@/services/analytics' +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' + +export const RequiredConfirmation = ({ threshold, owners }: { threshold: number; owners: number }) => { + const { setTxFlow } = useContext(TxModalContext) + + return ( + + + + + Required confirmations + + + + + + Any transaction requires the confirmation of: + + + + {threshold} out of {owners} signer{maybePlural(owners)}. + + + {owners > 1 && ( + + {(isOk) => ( + + + + )} + + )} + + + + ) +} diff --git a/apps/web/src/components/settings/SafeAppsPermissions/index.tsx b/apps/web/src/components/settings/SafeAppsPermissions/index.tsx new file mode 100644 index 000000000..efd312270 --- /dev/null +++ b/apps/web/src/components/settings/SafeAppsPermissions/index.tsx @@ -0,0 +1,195 @@ +import { useSafeApps } from '@/hooks/safe-apps/useSafeApps' +import { + getBrowserPermissionDisplayValues, + getSafePermissionDisplayValues, + useBrowserPermissions, + useSafePermissions, +} from '@/hooks/safe-apps/permissions' +import type { ReactElement } from 'react' +import { useCallback, useMemo } from 'react' +import type { AllowedFeatures } from '@/components/safe-apps/types' +import { PermissionStatus } from '@/components/safe-apps/types' +import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' +import { Grid, Link, Paper, SvgIcon, Typography } from '@mui/material' +import PermissionsCheckbox from '@/components/safe-apps/PermissionCheckbox' +import DeleteIcon from '@/public/images/common/delete.svg' + +const SafeAppsPermissions = (): ReactElement => { + const { allSafeApps } = useSafeApps() + const { + permissions: safePermissions, + updatePermission: updateSafePermission, + removePermissions: removeSafePermissions, + isUserRestricted, + } = useSafePermissions() + const { + permissions: browserPermissions, + updatePermission: updateBrowserPermission, + removePermissions: removeBrowserPermissions, + } = useBrowserPermissions() + const domains = useMemo(() => { + const mergedPermissionsSet = new Set(Object.keys(browserPermissions).concat(Object.keys(safePermissions))) + + return Array.from(mergedPermissionsSet) + }, [safePermissions, browserPermissions]) + + const handleSafePermissionsChange = (origin: string, capability: string, checked: boolean) => + updateSafePermission(origin, [{ capability, selected: checked }]) + + const handleBrowserPermissionsChange = (origin: string, feature: AllowedFeatures, checked: boolean) => + updateBrowserPermission(origin, [{ feature, selected: checked }]) + + const updateAllPermissions = useCallback( + (origin: string, selected: boolean) => { + if (safePermissions[origin]?.length) + updateSafePermission( + origin, + safePermissions[origin].map(({ parentCapability }) => ({ capability: parentCapability, selected })), + ) + + if (browserPermissions[origin]?.length) + updateBrowserPermission( + origin, + browserPermissions[origin].map(({ feature }) => ({ feature, selected })), + ) + }, + [browserPermissions, safePermissions, updateBrowserPermission, updateSafePermission], + ) + + const handleAllowAll = useCallback( + (event: React.MouseEvent, origin: string) => { + event.preventDefault() + updateAllPermissions(origin, true) + }, + [updateAllPermissions], + ) + + const handleClearAll = useCallback( + (event: React.MouseEvent, origin: string) => { + event.preventDefault() + updateAllPermissions(origin, false) + }, + [updateAllPermissions], + ) + + const handleRemoveApp = useCallback( + (event: React.MouseEvent, origin: string) => { + event.preventDefault() + removeSafePermissions(origin) + removeBrowserPermissions(origin) + }, + [removeBrowserPermissions, removeSafePermissions], + ) + + const appNames = useMemo(() => { + const appNames = allSafeApps.reduce((acc: Record, app: SafeAppData) => { + acc[app.url] = app.name + return acc + }, {}) + + return appNames + }, [allSafeApps]) + + if (!allSafeApps.length) { + return
+ } + + return ( + + + Safe Apps permissions + +
+ {!domains.length && ( + palette.primary.light }}> + There are no Safe Apps using permissions. + + )} + {domains.map((domain) => ( + ({ + border: `1px solid ${palette.border.light}`, + borderRadius: shape.borderRadius, + marginBottom: '16px', + })} + > + ({ + padding: '15px 24px', + borderBottom: `1px solid ${palette.border.light}`, + })} + > + + + {appNames[domain]} + + {domain} + + + {safePermissions[domain]?.map(({ parentCapability, caveats }) => { + return ( + + handleSafePermissionsChange(domain, parentCapability, checked)} + checked={!isUserRestricted(caveats)} + /> + + ) + })} + {browserPermissions[domain]?.map(({ feature, status }) => { + return ( + + handleBrowserPermissionsChange(domain, feature, checked)} + checked={status === PermissionStatus.GRANTED ? true : false} + /> + + ) + })} + + + + handleAllowAll(event, domain)} sx={{ textDecoration: 'none' }}> + Allow all + + handleClearAll(event, domain)} + sx={{ textDecoration: 'none' }} + ml={2} + > + Clear all + + handleRemoveApp(event, domain)} ml={2}> + + + + + ))} +
+ ) +} + +export default SafeAppsPermissions diff --git a/src/components/settings/SafeAppsSigningMethod/index.test.tsx b/apps/web/src/components/settings/SafeAppsSigningMethod/index.test.tsx similarity index 100% rename from src/components/settings/SafeAppsSigningMethod/index.test.tsx rename to apps/web/src/components/settings/SafeAppsSigningMethod/index.test.tsx diff --git a/apps/web/src/components/settings/SafeAppsSigningMethod/index.tsx b/apps/web/src/components/settings/SafeAppsSigningMethod/index.tsx new file mode 100644 index 000000000..c4b25059a --- /dev/null +++ b/apps/web/src/components/settings/SafeAppsSigningMethod/index.tsx @@ -0,0 +1,49 @@ +import ExternalLink from '@/components/common/ExternalLink' +import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' +import { useAppDispatch, useAppSelector } from '@/store' +import { selectOnChainSigning, setOnChainSigning } from '@/store/settingsSlice' +import { FormControlLabel, Checkbox, Paper, Typography, FormGroup, Grid } from '@mui/material' +import { BRAND_NAME, HelpCenterArticle } from '@/config/constants' + +export const SafeAppsSigningMethod = () => { + const onChainSigning = useAppSelector(selectOnChainSigning) + + const dispatch = useAppDispatch() + + const onChange = () => { + trackEvent(SETTINGS_EVENTS.SAFE_APPS.CHANGE_SIGNING_METHOD) + dispatch(setOnChainSigning(!onChainSigning)) + } + + return ( + + + + + Signing method + + + + + + This setting determines how the {BRAND_NAME} will sign message requests from Safe Apps. Gasless, off-chain + signing is used by default. Learn more about message signing{' '} + here. + + + ({ + flex: 1, + '.MuiIconButton-root:not(.Mui-checked)': { + color: palette.text.disabled, + }, + })} + control={} + label="Always use on-chain signatures" + /> + + + + + ) +} diff --git a/src/components/settings/SafeModules/__tests__/SafeModules.test.tsx b/apps/web/src/components/settings/SafeModules/__tests__/SafeModules.test.tsx similarity index 100% rename from src/components/settings/SafeModules/__tests__/SafeModules.test.tsx rename to apps/web/src/components/settings/SafeModules/__tests__/SafeModules.test.tsx diff --git a/apps/web/src/components/settings/SafeModules/index.tsx b/apps/web/src/components/settings/SafeModules/index.tsx new file mode 100644 index 000000000..443894826 --- /dev/null +++ b/apps/web/src/components/settings/SafeModules/index.tsx @@ -0,0 +1,105 @@ +import EthHashInfo from '@/components/common/EthHashInfo' +import useSafeInfo from '@/hooks/useSafeInfo' +import { Paper, Grid, Typography, Box, IconButton, SvgIcon } from '@mui/material' + +import ExternalLink from '@/components/common/ExternalLink' +import { RemoveModuleFlow } from '@/components/tx-flow/flows' +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 { selectDelayModifierByAddress } from '@/features/recovery/services/selectors' +import { RemoveRecoveryFlow } from '@/components/tx-flow/flows' +import useRecovery from '@/features/recovery/hooks/useRecovery' + +import css from '../TransactionGuards/styles.module.css' + +const NoModules = () => { + return ( + palette.primary.light }}> + No modules enabled + + ) +} + +const ModuleDisplay = ({ moduleAddress, chainId, name }: { moduleAddress: string; chainId: string; name?: string }) => { + const { setTxFlow } = useContext(TxModalContext) + const [recovery] = useRecovery() + const delayModifier = recovery && selectDelayModifierByAddress(recovery, moduleAddress) + + const onRemove = () => { + if (delayModifier) { + setTxFlow() + } else { + setTxFlow() + } + } + + return ( + + + + {(isOk) => ( + + + + )} + + + ) +} + +const SafeModules = () => { + const { safe } = useSafeInfo() + const safeModules = safe.modules || [] + + return ( + + + + + Safe modules + + + + + + + Modules allow you to customize the access-control logic of your Safe Account. Modules are potentially + risky, so make sure to only use modules from trusted sources. Learn more about modules{' '} + here + + {safeModules.length === 0 ? ( + + ) : ( + safeModules.map((module) => ( + + )) + )} + + + + + ) +} + +export default SafeModules diff --git a/src/components/settings/SecurityLogin/index.tsx b/apps/web/src/components/settings/SecurityLogin/index.tsx similarity index 100% rename from src/components/settings/SecurityLogin/index.tsx rename to apps/web/src/components/settings/SecurityLogin/index.tsx diff --git a/src/components/settings/SecuritySettings/index.tsx b/apps/web/src/components/settings/SecuritySettings/index.tsx similarity index 100% rename from src/components/settings/SecuritySettings/index.tsx rename to apps/web/src/components/settings/SecuritySettings/index.tsx diff --git a/src/components/settings/SettingsHeader/index.test.tsx b/apps/web/src/components/settings/SettingsHeader/index.test.tsx similarity index 100% rename from src/components/settings/SettingsHeader/index.test.tsx rename to apps/web/src/components/settings/SettingsHeader/index.test.tsx diff --git a/src/components/settings/SettingsHeader/index.tsx b/apps/web/src/components/settings/SettingsHeader/index.tsx similarity index 100% rename from src/components/settings/SettingsHeader/index.tsx rename to apps/web/src/components/settings/SettingsHeader/index.tsx diff --git a/src/components/settings/SpendingLimits/NoSpendingLimits.tsx b/apps/web/src/components/settings/SpendingLimits/NoSpendingLimits.tsx similarity index 90% rename from src/components/settings/SpendingLimits/NoSpendingLimits.tsx rename to apps/web/src/components/settings/SpendingLimits/NoSpendingLimits.tsx index 41f37fb54..e67ac8ea7 100644 --- a/src/components/settings/SpendingLimits/NoSpendingLimits.tsx +++ b/apps/web/src/components/settings/SpendingLimits/NoSpendingLimits.tsx @@ -6,7 +6,15 @@ import TimeIcon from '@/public/images/settings/spending-limit/time.svg' export const NoSpendingLimits = () => { return ( - + @@ -19,7 +27,6 @@ export const NoSpendingLimits = () => { Safe Account - @@ -29,7 +36,6 @@ export const NoSpendingLimits = () => { You can set allowances for any asset stored in your Safe Account - diff --git a/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx b/apps/web/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx similarity index 100% rename from src/components/settings/SpendingLimits/SpendingLimitsTable.tsx rename to apps/web/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx diff --git a/apps/web/src/components/settings/SpendingLimits/index.tsx b/apps/web/src/components/settings/SpendingLimits/index.tsx new file mode 100644 index 000000000..9f26cd8b6 --- /dev/null +++ b/apps/web/src/components/settings/SpendingLimits/index.tsx @@ -0,0 +1,80 @@ +import { useContext } from 'react' +import { Paper, Grid, Typography, Box, Button } from '@mui/material' +import { NoSpendingLimits } from '@/components/settings/SpendingLimits/NoSpendingLimits' +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' + +const SpendingLimits = () => { + const { setTxFlow } = useContext(TxModalContext) + const spendingLimits = useSelector(selectSpendingLimits) + const spendingLimitsLoading = useSelector(selectSpendingLimitsLoading) + const isEnabled = useHasFeature(FEATURES.SPENDING_LIMIT) + + return ( + + + + + Spending limits + + + + + {isEnabled ? ( + + + You can set rules for specific beneficiaries to access funds from this Safe Account without having to + collect all signatures. + + + + {(isOk) => ( + + + + )} + + + {!spendingLimits.length && !spendingLimitsLoading && } + + ) : ( + The spending limit module is not yet available on this chain. + )} + + + + + ) +} + +export default SpendingLimits diff --git a/src/components/settings/TransactionGuards/__tests__/TransactionGuards.test.tsx b/apps/web/src/components/settings/TransactionGuards/__tests__/TransactionGuards.test.tsx similarity index 100% rename from src/components/settings/TransactionGuards/__tests__/TransactionGuards.test.tsx rename to apps/web/src/components/settings/TransactionGuards/__tests__/TransactionGuards.test.tsx diff --git a/apps/web/src/components/settings/TransactionGuards/index.tsx b/apps/web/src/components/settings/TransactionGuards/index.tsx new file mode 100644 index 000000000..4fe1c357f --- /dev/null +++ b/apps/web/src/components/settings/TransactionGuards/index.tsx @@ -0,0 +1,84 @@ +import EthHashInfo from '@/components/common/EthHashInfo' +import useSafeInfo from '@/hooks/useSafeInfo' +import { Paper, Grid, Typography, Box, IconButton, SvgIcon } from '@mui/material' + +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' + +const NoTransactionGuard = () => { + return ( + palette.primary.light }}> + No transaction guard set + + ) +} + +const GuardDisplay = ({ guardAddress, chainId }: { guardAddress: string; chainId: string }) => { + const { setTxFlow } = useContext(TxModalContext) + + return ( + + + + {(isOk) => ( + setTxFlow()} + color="error" + size="small" + disabled={!isOk} + > + + + )} + + + ) +} + +const TransactionGuards = () => { + const { safe, safeLoaded } = useSafeInfo() + + const isVersionWithGuards = safeLoaded && hasSafeFeature(SAFE_FEATURES.SAFE_TX_GUARDS, safe.version) + + if (!isVersionWithGuards) { + return null + } + + return ( + + + + + Transaction guards + + + + + + + Transaction guards impose additional constraints that are checked prior to executing a Safe transaction. + Transaction guards are potentially risky, so make sure to only use transaction guards from trusted + sources. Learn more about transaction guards{' '} + here. + + {safe.guard ? ( + + ) : ( + + )} + + + + + ) +} + +export default TransactionGuards diff --git a/src/components/settings/TransactionGuards/styles.module.css b/apps/web/src/components/settings/TransactionGuards/styles.module.css similarity index 100% rename from src/components/settings/TransactionGuards/styles.module.css rename to apps/web/src/components/settings/TransactionGuards/styles.module.css diff --git a/apps/web/src/components/settings/owner/EditOwnerDialog/index.tsx b/apps/web/src/components/settings/owner/EditOwnerDialog/index.tsx new file mode 100644 index 000000000..b12aab771 --- /dev/null +++ b/apps/web/src/components/settings/owner/EditOwnerDialog/index.tsx @@ -0,0 +1,86 @@ +import EthHashInfo from '@/components/common/EthHashInfo' +import ModalDialog from '@/components/common/ModalDialog' +import NameInput from '@/components/common/NameInput' +import Track from '@/components/common/Track' +import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' +import { useAppDispatch } from '@/store' +import EditIcon from '@/public/images/common/edit.svg' +import { Box, Button, DialogActions, DialogContent, IconButton, Tooltip, SvgIcon } from '@mui/material' +import { useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' + +type EditOwnerValues = { + name: string +} + +export const EditOwnerDialog = ({ chainId, address, name }: { chainId: string; address: string; name?: string }) => { + const [open, setOpen] = useState(false) + + const dispatch = useAppDispatch() + + const handleClose = () => setOpen(false) + + const onSubmit = (data: EditOwnerValues) => { + if (data.name !== name) { + dispatch( + upsertAddressBookEntries({ + chainIds: [chainId], + address, + name: data.name, + }), + ) + handleClose() + } + } + + const formMethods = useForm({ + defaultValues: { + name: name || '', + }, + mode: 'onChange', + }) + + const { handleSubmit, formState, watch } = formMethods + + const nameValue = watch('name') + + const buttonDisabled = !formState.isValid || nameValue === name || nameValue === '' + + return ( + <> + + + + setOpen(true)} size="small"> + + + + + + + + +
+ + + + + + + + + + + + + + +
+
+
+ + ) +} diff --git a/apps/web/src/components/settings/owner/OwnerList/index.tsx b/apps/web/src/components/settings/owner/OwnerList/index.tsx new file mode 100644 index 000000000..513cae19d --- /dev/null +++ b/apps/web/src/components/settings/owner/OwnerList/index.tsx @@ -0,0 +1,179 @@ +import { jsonToCSV } from 'react-papaparse' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import EthHashInfo from '@/components/common/EthHashInfo' +import { AddOwnerFlow, 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 Track from '@/components/common/Track' +import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' +import CheckWallet from '@/components/common/CheckWallet' +import { TxModalContext } from '@/components/tx-flow' +import ReplaceOwnerIcon from '@/public/images/settings/setup/replace-owner.svg' +import DeleteIcon from '@/public/images/common/delete.svg' +import type { AddressBook } from '@/store/addressBookSlice' + +import tableCss from '@/components/common/EnhancedTable/styles.module.css' + +export const OwnerList = () => { + const addressBook = useAddressBook() + const { safe } = useSafeInfo() + const { setTxFlow } = useContext(TxModalContext) + + const rows = useMemo(() => { + const showRemoveOwnerButton = safe.owners.length > 1 + + return safe.owners.map((owner) => { + const address = owner.value + const name = addressBook[address] + + return { + cells: { + owner: { + rawValue: address, + content: , + }, + actions: { + rawValue: '', + sticky: true, + content: ( +
+ + {(isOk) => ( + + + + setTxFlow()} + size="small" + disabled={!isOk} + > + + + + + + )} + + + + + {showRemoveOwnerButton && ( + + {(isOk) => ( + + + + setTxFlow()} + size="small" + disabled={!isOk} + > + + + + + + )} + + )} +
+ ), + }, + }, + } + }) + }, [safe.owners, safe.chainId, addressBook, setTxFlow]) + + return ( + + + + + Members + + + + + + Signers + + + Signers have full control over the account, they can propose, sign and execute transactions, as well as + reject them. + + + + + {(isOk) => ( + + + + )} + + + + + + + + + + ) +} + +function exportOwners({ chainId, address, owners }: SafeInfo, addressBook: AddressBook) { + const json = owners.map((owner) => { + const address = owner.value + const name = addressBook[address] || owner.name + return [address, name] + }) + + const csv = jsonToCSV(json) + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + + Object.assign(link, { + download: `${chainId}-${address.value}-signers.csv`, + href: window.URL.createObjectURL(blob), + }) + + link.click() +} diff --git a/apps/web/src/components/sidebar/DebugToggle/index.tsx b/apps/web/src/components/sidebar/DebugToggle/index.tsx new file mode 100644 index 000000000..344bdd355 --- /dev/null +++ b/apps/web/src/components/sidebar/DebugToggle/index.tsx @@ -0,0 +1,34 @@ +import { type ChangeEvent, type ReactElement } from 'react' +import { Box, FormControlLabel, Switch } from '@mui/material' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { setDarkMode } from '@/store/settingsSlice' +import { useDarkMode } from '@/hooks/useDarkMode' +import { useAppDispatch } from '@/store' +import { LS_KEY } from '@/config/gateway' + +const DebugToggle = (): ReactElement => { + const dispatch = useAppDispatch() + const isDarkMode = useDarkMode() + + const [isProdGateway = false, setIsProdGateway] = useLocalStorage(LS_KEY) + + const onToggleGateway = (event: ChangeEvent) => { + setIsProdGateway(event.target.checked) + + setTimeout(() => { + location.reload() + }, 300) + } + + return ( + + dispatch(setDarkMode(checked))} />} + label="Dark mode" + /> + } label="Use prod CGW" /> + + ) +} + +export default DebugToggle diff --git a/apps/web/src/components/sidebar/IndexingStatus/index.tsx b/apps/web/src/components/sidebar/IndexingStatus/index.tsx new file mode 100644 index 000000000..c80501473 --- /dev/null +++ b/apps/web/src/components/sidebar/IndexingStatus/index.tsx @@ -0,0 +1,79 @@ +import { Stack, Box, Typography, Tooltip } from '@mui/material' +import { formatDistanceToNow } from 'date-fns' +import { getIndexingStatus } from '@safe-global/safe-gateway-typescript-sdk' +import useAsync from '@/hooks/useAsync' +import useChainId from '@/hooks/useChainId' +import ExternalLink from '@/components/common/ExternalLink' +import useIntervalCounter from '@/hooks/useIntervalCounter' + +const STATUS_PAGE = 'https://status.safe.global' +const MAX_SYNC_DELAY = 1000 * 60 * 5 // 5 minutes +const POLL_INTERVAL = 1000 * 60 // 1 minute + +const useIndexingStatus = () => { + const chainId = useChainId() + const [count] = useIntervalCounter(POLL_INTERVAL) + + return useAsync( + () => { + return getIndexingStatus(chainId) + }, + [chainId, count], + false, + ) +} + +const STATUSES = { + synced: { + color: 'success', + text: 'Synced', + }, + slow: { + color: 'warning', + text: 'Slow network', + }, + outOfSync: { + color: 'error', + text: 'Out of sync', + }, +} + +const getStatus = (synced: boolean, lastSync: number) => { + let status = STATUSES.outOfSync + + if (synced) { + status = STATUSES.synced + } else if (Date.now() - lastSync > MAX_SYNC_DELAY) { + status = STATUSES.slow + } + + return status +} + +const IndexingStatus = () => { + const [data] = useIndexingStatus() + + if (!data) { + return null + } + + const status = getStatus(data.synced, data.lastSync) + + const time = formatDistanceToNow(data.lastSync, { addSuffix: true }) + + return ( + + + + + + {status.text} + + + + + + ) +} + +export default IndexingStatus diff --git a/apps/web/src/components/sidebar/NewTxButton/index.tsx b/apps/web/src/components/sidebar/NewTxButton/index.tsx new file mode 100644 index 000000000..9f953643e --- /dev/null +++ b/apps/web/src/components/sidebar/NewTxButton/index.tsx @@ -0,0 +1,48 @@ +import ActivateAccountButton from '@/features/counterfactual/ActivateAccountButton' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import { type ReactElement, useContext } from 'react' +import Button from '@mui/material/Button' +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) + const isCounterfactualSafe = useIsCounterfactualSafe() + + const onClick = () => { + setTxFlow(, undefined, false) + trackEvent({ ...OVERVIEW_EVENTS.NEW_TRANSACTION, label: 'sidebar' }) + } + + if (isCounterfactualSafe) { + return + } + + return ( + + {(isOk) => + isOk ? ( + + ) : ( + + ) + } + + ) +} + +export default NewTxButton diff --git a/src/components/sidebar/QrCodeButton/QrModal.tsx b/apps/web/src/components/sidebar/QrCodeButton/QrModal.tsx similarity index 91% rename from src/components/sidebar/QrCodeButton/QrModal.tsx rename to apps/web/src/components/sidebar/QrCodeButton/QrModal.tsx index 50f5b15e8..04ec8df5b 100644 --- a/src/components/sidebar/QrCodeButton/QrModal.tsx +++ b/apps/web/src/components/sidebar/QrCodeButton/QrModal.tsx @@ -47,7 +47,13 @@ const QrModal = ({ onClose }: { onClose: () => void }): ReactElement => { /> - + 0} + hasExplorer + showCopyButton + /> diff --git a/src/components/sidebar/QrCodeButton/index.tsx b/apps/web/src/components/sidebar/QrCodeButton/index.tsx similarity index 100% rename from src/components/sidebar/QrCodeButton/index.tsx rename to apps/web/src/components/sidebar/QrCodeButton/index.tsx diff --git a/apps/web/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx b/apps/web/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx new file mode 100644 index 000000000..79fb34c4c --- /dev/null +++ b/apps/web/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx @@ -0,0 +1,109 @@ +import type { MouseEvent } from 'react' +import { useState, type ReactElement } from 'react' +import ListItemIcon from '@mui/material/ListItemIcon' +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 EntryDialog from '@/components/address-book/EntryDialog' +import EditIcon from '@/public/images/common/edit.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 { SvgIcon } from '@mui/material' +import { AppRoutes } from '@/config/routes' +import router from 'next/router' +import { CreateSafeOnNewChain } from '@/features/multichain/components/CreateSafeOnNewChain' + +enum ModalType { + RENAME = 'rename', + ADD_CHAIN = 'add_chain', +} + +const defaultOpen = { [ModalType.RENAME]: false, [ModalType.ADD_CHAIN]: false } + +const MultiAccountContextMenu = ({ + name, + address, + chainIds, + addNetwork, +}: { + name: string + address: string + chainIds: string[] + addNetwork: boolean +}): ReactElement => { + const [anchorEl, setAnchorEl] = useState() + const [open, setOpen] = useState(defaultOpen) + + const handleOpenContextMenu = (e: MouseEvent) => { + e.stopPropagation() + setAnchorEl(e.currentTarget) + } + + const handleCloseContextMenu = (event: MouseEvent) => { + event.stopPropagation() + setAnchorEl(undefined) + } + + const handleOpenModal = + (type: ModalType, event: typeof OVERVIEW_EVENTS.SIDEBAR_RENAME | typeof OVERVIEW_EVENTS.ADD_NEW_NETWORK) => + (e: MouseEvent) => { + const trackingLabel = + router.pathname === AppRoutes.welcome.accounts ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + handleCloseContextMenu(e) + setOpen((prev) => ({ ...prev, [type]: true })) + + trackEvent({ ...event, label: trackingLabel }) + } + + const handleCloseModal = () => { + setOpen(defaultOpen) + } + + return ( + <> + + ({ color: palette.border.main })} /> + + + + + + + Rename + + {addNetwork && ( + + + + + Add another network + + )} + + + {open[ModalType.RENAME] && ( + + )} + + {open[ModalType.ADD_CHAIN] && ( + + )} + + ) +} + +export default MultiAccountContextMenu diff --git a/apps/web/src/components/sidebar/SafeListContextMenu/index.tsx b/apps/web/src/components/sidebar/SafeListContextMenu/index.tsx new file mode 100644 index 000000000..d71511046 --- /dev/null +++ b/apps/web/src/components/sidebar/SafeListContextMenu/index.tsx @@ -0,0 +1,135 @@ +import type { MouseEvent } from 'react' +import { useState, type ReactElement } from 'react' +import ListItemIcon from '@mui/material/ListItemIcon' +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 EntryDialog from '@/components/address-book/EntryDialog' +import SafeListRemoveDialog from '@/components/sidebar/SafeListRemoveDialog' +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 { 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' + +enum ModalType { + RENAME = 'rename', + REMOVE = 'remove', + ADD_CHAIN = 'add_chain', +} + +const defaultOpen = { [ModalType.RENAME]: false, [ModalType.REMOVE]: false, [ModalType.ADD_CHAIN]: false } + +const SafeListContextMenu = ({ + name, + address, + chainId, + addNetwork, + rename, + undeployedSafe, +}: { + name: string + address: string + chainId: string + addNetwork: boolean + rename: boolean + undeployedSafe: boolean +}): ReactElement => { + const addressBook = useAddressBook() + const hasName = address in addressBook + + const [anchorEl, setAnchorEl] = useState() + const [open, setOpen] = useState(defaultOpen) + + const trackingLabel = + router.pathname === AppRoutes.welcome.accounts ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + const handleOpenContextMenu = (e: MouseEvent) => { + setAnchorEl(e.currentTarget) + } + + const handleCloseContextMenu = () => { + setAnchorEl(undefined) + } + + const handleOpenModal = + (type: keyof typeof open, event: typeof OVERVIEW_EVENTS.SIDEBAR_RENAME | typeof OVERVIEW_EVENTS.SIDEBAR_RENAME) => + () => { + handleCloseContextMenu() + setOpen((prev) => ({ ...prev, [type]: true })) + + trackEvent({ ...event, label: trackingLabel }) + } + + const handleCloseModal = () => { + setOpen(defaultOpen) + } + + return ( + <> + + ({ color: palette.border.main })} /> + + + {rename && ( + + + + + {hasName ? 'Rename' : 'Give name'} + + )} + + {undeployedSafe && ( + + + + + Remove + + )} + + {addNetwork && ( + + + + + Add another network + + )} + + + {open[ModalType.RENAME] && ( + + )} + + {open[ModalType.REMOVE] && ( + + )} + + {open[ModalType.ADD_CHAIN] && ( + + )} + + ) +} + +export default SafeListContextMenu diff --git a/apps/web/src/components/sidebar/SafeListRemoveDialog/index.tsx b/apps/web/src/components/sidebar/SafeListRemoveDialog/index.tsx new file mode 100644 index 000000000..03ecfed2e --- /dev/null +++ b/apps/web/src/components/sidebar/SafeListRemoveDialog/index.tsx @@ -0,0 +1,70 @@ +import DialogContent from '@mui/material/DialogContent' +import DialogActions from '@mui/material/DialogActions' +import Typography from '@mui/material/Typography' +import Button from '@mui/material/Button' +import type { ReactElement } from 'react' + +import ModalDialog from '@/components/common/ModalDialog' +import { useAppDispatch } from '@/store' +import useAddressBook from '@/hooks/useAddressBook' +import Track from '@/components/common/Track' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { AppRoutes } from '@/config/routes' +import router from 'next/router' +import { removeAddressBookEntry } from '@/store/addressBookSlice' +import { removeSafe, removeUndeployedSafe } from '@/store/slices' +import useSafeAddress from '@/hooks/useSafeAddress' +import useChainId from '@/hooks/useChainId' + +const SafeListRemoveDialog = ({ + handleClose, + address, + chainId, +}: { + handleClose: () => void + address: string + chainId: string +}): ReactElement => { + const dispatch = useAppDispatch() + const safeAddress = useSafeAddress() + const safeChainId = useChainId() + const addressBook = useAddressBook() + const trackingLabel = + router.pathname === AppRoutes.welcome.accounts ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + const safe = addressBook?.[address] || address + + const handleConfirm = async () => { + // When removing the current counterfactual safe, redirect to the accounts page + if (safeAddress === address && safeChainId === chainId) { + await router.push(AppRoutes.welcome.accounts) + } + dispatch(removeUndeployedSafe({ chainId, address })) + dispatch(removeSafe({ chainId, address })) + dispatch(removeAddressBookEntry({ chainId, address })) + handleClose() + } + + return ( + + + + Are you sure you want to remove the {safe} account? + + + + + + + + + + + ) +} + +export default SafeListRemoveDialog diff --git a/apps/web/src/components/sidebar/Sidebar/index.tsx b/apps/web/src/components/sidebar/Sidebar/index.tsx new file mode 100644 index 000000000..28d7cab86 --- /dev/null +++ b/apps/web/src/components/sidebar/Sidebar/index.tsx @@ -0,0 +1,69 @@ +import { useCallback, useState, type ReactElement } from 'react' +import { Box, Divider, Drawer } from '@mui/material' +import ChevronRight from '@mui/icons-material/ChevronRight' + +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 css from './styles.module.css' +import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' +import MyAccounts from '@/features/myAccounts' + +const Sidebar = (): ReactElement => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false) + + const onDrawerToggle = useCallback(() => { + setIsDrawerOpen((isOpen) => { + trackEvent({ ...OVERVIEW_EVENTS.SIDEBAR, label: isOpen ? 'Close' : 'Open' }) + + return !isOpen + }) + }, []) + + const closeDrawer = useCallback(() => setIsDrawerOpen(false), []) + + return ( +
+
+ + + {/* Open the safes list */} + + + {/* Address, balance, copy button, etc */} + + + + + {/* Nav menu */} + + + + + + + {/* What's new + Need help? */} + + {/* + + + */} +
+ +
+ +
+
+
+ ) +} + +export default Sidebar diff --git a/apps/web/src/components/sidebar/Sidebar/styles.module.css b/apps/web/src/components/sidebar/Sidebar/styles.module.css new file mode 100644 index 000000000..b90513e72 --- /dev/null +++ b/apps/web/src/components/sidebar/Sidebar/styles.module.css @@ -0,0 +1,85 @@ +.container { + height: 100vh; + padding-top: var(--header-height); + display: flex; + overflow: hidden; + flex-direction: column; + background-color: var(--color-background-paper); +} + +.container { + width: 230px; +} + +.scroll { + display: flex; + flex-direction: column; + height: 100%; + position: relative; + overflow-y: auto; + overflow-x: hidden; +} + +.drawer { + width: 550px; + max-width: 90vw; + padding-top: var(--header-height); + border-right: 1px solid var(--color-border-light); + overflow-y: auto; + height: 100%; +} + +.dataWidget { + margin-top: var(--space-4); + border-top: 1px solid var(--color-border-light); +} + +.noSafeHeader { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 10px; + min-height: 100px; +} + +.drawerButton { + position: absolute !important; + z-index: 2; + color: var(--color-text-primary); + padding: 8px 0; + right: 0; + transform: translateX(50%); + margin-top: 54px; + border-radius: 50%; + width: 40px; + height: 40px; + border: 0; + cursor: pointer; + background-color: var(--color-background-main); +} + +.drawerButton:hover { + background-color: var(--color-secondary-background); +} + +.drawerButton svg { + transform: translateX(-25%); +} + +@media (max-width: 899.95px) { + .container { + padding-top: var(--header-height); + border-right: 1px solid var(--color-border-light); + } + + .drawer { + max-width: 90vw; + } + + .drawerButton { + width: 60px; + height: 60px; + margin-top: 44px; + } +} diff --git a/apps/web/src/components/sidebar/SidebarFooter/index.tsx b/apps/web/src/components/sidebar/SidebarFooter/index.tsx new file mode 100644 index 000000000..b3f1c1a27 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarFooter/index.tsx @@ -0,0 +1,96 @@ +import type { ReactElement } from 'react' +import { useEffect } from 'react' + +import { + SidebarList, + SidebarListItemButton, + SidebarListItemIcon, + SidebarListItemText, +} from '@/components/sidebar/SidebarList' +import { loadBeamer } from '@/services/beamer' +import { useAppDispatch, useAppSelector } from '@/store' +import { CookieAndTermType, hasConsentFor } from '@/store/cookiesAndTermsSlice' +import { openCookieBanner } from '@/store/popupSlice' +import { Link, ListItem, SvgIcon, Typography } 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' +import ProtofireLogo from '@/public/images/protofire-logo.svg' + +const SidebarFooter = (): ReactElement => { + const dispatch = useAppDispatch() + const chain = useCurrentChain() + const hasBeamerConsent = useAppSelector((state) => hasConsentFor(state, CookieAndTermType.UPDATES)) + + useEffect(() => { + // Initialise Beamer when consent was previously given + if (hasBeamerConsent && chain?.shortName) { + loadBeamer(chain.shortName) + } + }, [hasBeamerConsent, chain?.shortName]) + + const _handleBeamer = () => { + if (!hasBeamerConsent) { + dispatch(openCookieBanner({ warningKey: CookieAndTermType.UPDATES })) + } + } + + return ( + + {!IS_PRODUCTION && ( + + + + )} + + + + + + + + + Need help? + + + + + {' '} + + + + + + + + New Features Suggestion? + + + + + + + + Supported by{' '} + + + Protofire + + + + + + ) +} + +export default SidebarFooter diff --git a/apps/web/src/components/sidebar/SidebarHeader/index.tsx b/apps/web/src/components/sidebar/SidebarHeader/index.tsx new file mode 100644 index 000000000..f5684cd49 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarHeader/index.tsx @@ -0,0 +1,124 @@ +import TokenAmount from '@/components/common/TokenAmount' +import CounterfactualStatusButton from '@/features/counterfactual/CounterfactualStatusButton' +import { type ReactElement } from 'react' +import Typography from '@mui/material/Typography' +import IconButton from '@mui/material/IconButton' +import Skeleton from '@mui/material/Skeleton' +import Tooltip from '@mui/material/Tooltip' + +import useSafeInfo from '@/hooks/useSafeInfo' +import SafeIcon from '@/components/common/SafeIcon' +import NewTxButton from '@/components/sidebar/NewTxButton' +import { useAppSelector } from '@/store' + +import css from './styles.module.css' +import QrIconBold from '@/public/images/sidebar/qr-bold.svg' +import CopyIconBold from '@/public/images/sidebar/copy-bold.svg' +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 EthHashInfo from '@/components/common/EthHashInfo' +import QrCodeButton from '../QrCodeButton' +import Track from '@/components/common/Track' +import { OVERVIEW_EVENTS } from '@/services/analytics/events/overview' +import { SvgIcon } from '@mui/material' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' +import EnvHintButton from '@/components/settings/EnvironmentVariables/EnvHintButton' +import useSafeAddress from '@/hooks/useSafeAddress' +import ExplorerButton from '@/components/common/ExplorerButton' +import CopyTooltip from '@/components/common/CopyTooltip' +import FiatValue from '@/components/common/FiatValue' +import { useAddressResolver } from '@/hooks/useAddressResolver' + +const SafeHeader = (): ReactElement => { + const { balances } = useVisibleBalances() + const safeAddress = useSafeAddress() + const { safe } = useSafeInfo() + const { threshold, owners } = safe + const chain = useCurrentChain() + const settings = useAppSelector(selectSettings) + const { ens } = useAddressResolver(safeAddress) + + const addressCopyText = ( + settings.shortName.copy && chain ? `${chain.shortName}:${safeAddress}` : safeAddress + ).toLowerCase() + + const blockExplorerLink = chain ? getBlockExplorerLink(chain, safeAddress) : undefined + + return ( +
+
+
+
+ {safeAddress ? ( + + ) : ( + + )} +
+ +
+ {safeAddress ? ( + + ) : ( + + + + + )} + + + {safe.deployed ? ( + balances.fiatTotal ? ( + + ) : ( + + ) + ) : ( + + )} + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ ) +} + +export default SafeHeader diff --git a/src/components/sidebar/SidebarHeader/styles.module.css b/apps/web/src/components/sidebar/SidebarHeader/styles.module.css similarity index 100% rename from src/components/sidebar/SidebarHeader/styles.module.css rename to apps/web/src/components/sidebar/SidebarHeader/styles.module.css diff --git a/apps/web/src/components/sidebar/SidebarList/index.tsx b/apps/web/src/components/sidebar/SidebarList/index.tsx new file mode 100644 index 000000000..ae5e59742 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarList/index.tsx @@ -0,0 +1,98 @@ +import type { ReactElement } from 'react' +import List, { type ListProps } from '@mui/material/List' +import ListItemButton, { type ListItemButtonProps } from '@mui/material/ListItemButton' +import ListItemIcon, { type ListItemIconProps } from '@mui/material/ListItemIcon' +import ListItemText, { type ListItemTextProps } from '@mui/material/ListItemText' +import Link from 'next/link' +import type { LinkProps } from 'next/link' +import Badge from '@mui/material/Badge' + +import css from './styles.module.css' + +export const SidebarList = ({ children, ...rest }: Omit): ReactElement => ( + + {children} + +) + +export const SidebarListItemButton = ({ + href, + children, + disabled, + ...rest +}: Omit & { href?: LinkProps['href'] }): ReactElement => { + const button = ( + + {children} + + ) + + return href ? ( + + {button} + + ) : ( + button + ) +} + +export const SidebarListItemIcon = ({ + children, + badge = false, + ...rest +}: Omit & { badge?: boolean }): ReactElement => ( + ({ + fill: palette.logo.main, + }), + }, + }} + {...rest} + > + + {children} + + +) + +export const SidebarListItemText = ({ + children, + bold = false, + ...rest +}: ListItemTextProps & { bold?: boolean }): ReactElement => ( + + {children} + +) + +export const SidebarListItemCounter = ({ count }: { count?: string }): ReactElement | null => + count ? ( + + ) : null diff --git a/src/components/sidebar/SidebarList/styles.module.css b/apps/web/src/components/sidebar/SidebarList/styles.module.css similarity index 100% rename from src/components/sidebar/SidebarList/styles.module.css rename to apps/web/src/components/sidebar/SidebarList/styles.module.css diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/apps/web/src/components/sidebar/SidebarNavigation/config.tsx similarity index 87% rename from src/components/sidebar/SidebarNavigation/config.tsx rename to apps/web/src/components/sidebar/SidebarNavigation/config.tsx index efa82d35f..edeb5f6ec 100644 --- a/src/components/sidebar/SidebarNavigation/config.tsx +++ b/apps/web/src/components/sidebar/SidebarNavigation/config.tsx @@ -7,7 +7,9 @@ import TransactionIcon from '@/public/images/sidebar/transactions.svg' import ABIcon from '@/public/images/sidebar/address-book.svg' import AppsIcon from '@/public/images/apps/apps-icon.svg' 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 { SvgIcon } from '@mui/material' import { Chip } from '@/components/common/Chip' @@ -16,6 +18,7 @@ export type NavItem = { icon?: ReactElement href: string tag?: ReactElement + disabled?: boolean } export const navItems: NavItem[] = [ @@ -29,11 +32,21 @@ export const navItems: NavItem[] = [ icon: , href: AppRoutes.balances.index, }, + { + label: 'Bridge', + icon: , + href: AppRoutes.bridge, + tag: , + }, { label: 'Swap', icon: , href: AppRoutes.swap, - tag: , + }, + { + label: 'Stake', + icon: , + href: AppRoutes.stake, }, { label: 'Transactions', diff --git a/apps/web/src/components/sidebar/SidebarNavigation/index.tsx b/apps/web/src/components/sidebar/SidebarNavigation/index.tsx new file mode 100644 index 000000000..d41a50a08 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarNavigation/index.tsx @@ -0,0 +1,135 @@ +import React, { useContext, useMemo, type ReactElement } from 'react' +import { useRouter } from 'next/router' +import { ListItemButton } from '@mui/material' +import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' + +import { + SidebarList, + SidebarListItemButton, + SidebarListItemCounter, + SidebarListItemIcon, + SidebarListItemText, +} from '@/components/sidebar/SidebarList' +import { type NavItem, navItems } from './config' +import useSafeInfo from '@/hooks/useSafeInfo' +import { AppRoutes } from '@/config/routes' +import { useQueuedTxsLength } from '@/hooks/useTxQueue' +import { useCurrentChain } from '@/hooks/useChains' +import { isRouteEnabled } from '@/utils/chains' +import { trackEvent } from '@/services/analytics' +import { SWAP_EVENTS, SWAP_LABELS } from '@/services/analytics/events/swaps' +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' + +const getSubdirectory = (pathname: string): string => { + return pathname.split('/')[1] +} + +const geoBlockedRoutes = [AppRoutes.bridge, AppRoutes.swap, AppRoutes.stake] + +const undeployedSafeBlockedRoutes = [AppRoutes.bridge, AppRoutes.swap, AppRoutes.stake, AppRoutes.apps.index] + +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 }, +} + +const Navigation = (): ReactElement => { + const chain = useCurrentChain() + const router = useRouter() + const { safe } = useSafeInfo() + const currentSubdirectory = getSubdirectory(router.pathname) + const queueSize = useQueuedTxsLength() + const isBlockedCountry = useContext(GeoblockingContext) + + const visibleNavItems = useMemo(() => { + return navItems.filter((item) => { + if (isBlockedCountry && geoBlockedRoutes.includes(item.href)) { + return false + } + + return isRouteEnabled(item.href, chain) + }) + }, [chain, isBlockedCountry]) + + const enabledNavItems = useMemo(() => { + return safe.deployed + ? visibleNavItems + : visibleNavItems.filter((item) => !undeployedSafeBlockedRoutes.includes(item.href)) + }, [safe.deployed, visibleNavItems]) + + const getBadge = (item: NavItem) => { + // Indicate whether the current Safe needs an upgrade + if (item.href === AppRoutes.settings.setup) { + return safe.implementationVersionState === ImplementationVersionState.OUTDATED + } + } + + // Route Transactions to Queue if there are queued txs, otherwise to History + const getRoute = (href: string) => { + if (href === AppRoutes.transactions.history && queueSize) { + return AppRoutes.transactions.queue + } + return href + } + + const handleNavigationClick = (href: string) => { + const eventInfo = customSidebarEvents[href] + if (eventInfo) { + trackEvent({ ...eventInfo.event, label: eventInfo.label }) + } + } + + return ( + + {visibleNavItems.map((item) => { + const isSelected = currentSubdirectory === getSubdirectory(item.href) + const isDisabled = item.disabled || !enabledNavItems.includes(item) + let ItemTag = item.tag ? item.tag : null + + if (item.href === AppRoutes.transactions.history) { + ItemTag = queueSize ? : null + } + + return ( + +
+ handleNavigationClick(item.href)} + key={item.href} + > + + {item.icon && {item.icon}} + + + {item.label} + + {ItemTag} + + + +
+
+ ) + })} +
+ ) +} + +export default React.memo(Navigation) diff --git a/apps/web/src/components/sidebar/WatchlistAddButton/index.tsx b/apps/web/src/components/sidebar/WatchlistAddButton/index.tsx new file mode 100644 index 000000000..f0df8fff3 --- /dev/null +++ b/apps/web/src/components/sidebar/WatchlistAddButton/index.tsx @@ -0,0 +1,73 @@ +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 ? ( + + + + ) : ( + + + + )} + + {open && chainId && ( + setOpen(false)} address={address} chainId={chainId} /> + )} + + ) +} + +export default WatchlistAddButton diff --git a/src/components/theme/SafeThemeProvider.tsx b/apps/web/src/components/theme/SafeThemeProvider.tsx similarity index 100% rename from src/components/theme/SafeThemeProvider.tsx rename to apps/web/src/components/theme/SafeThemeProvider.tsx diff --git a/src/components/theme/darkPalette.ts b/apps/web/src/components/theme/darkPalette.ts similarity index 100% rename from src/components/theme/darkPalette.ts rename to apps/web/src/components/theme/darkPalette.ts diff --git a/src/components/theme/lightPalette.ts b/apps/web/src/components/theme/lightPalette.ts similarity index 100% rename from src/components/theme/lightPalette.ts rename to apps/web/src/components/theme/lightPalette.ts diff --git a/src/components/theme/safeTheme.ts b/apps/web/src/components/theme/safeTheme.ts similarity index 97% rename from src/components/theme/safeTheme.ts rename to apps/web/src/components/theme/safeTheme.ts index e9108c325..b3ec8675b 100644 --- a/src/components/theme/safeTheme.ts +++ b/apps/web/src/components/theme/safeTheme.ts @@ -49,6 +49,7 @@ declare module '@mui/material/SvgIcon' { declare module '@mui/material/Button' { export interface ButtonPropsSizeOverrides { stretched: true + compact: true } export interface ButtonPropsColorOverrides { @@ -100,6 +101,12 @@ const createSafeTheme = (mode: PaletteMode): Theme => { }, MuiButton: { variants: [ + { + props: { size: 'compact' }, + style: { + padding: '8px 12px', + }, + }, { props: { size: 'stretched' }, style: { @@ -186,20 +193,17 @@ const createSafeTheme = (mode: PaletteMode): Theme => { margin: 0, borderColor: theme.palette.secondary.light, }, - - '&.Mui-expanded > .MuiAccordionSummary-root': { - background: theme.palette.background.light, - }, }), }, }, MuiAccordionSummary: { styleOverrides: { - root: { + root: ({ theme }) => ({ '&.Mui-expanded': { minHeight: '48px', + background: theme.palette.background.light, }, - }, + }), content: { '&.Mui-expanded': { margin: '12px 0', @@ -299,7 +303,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.error.background, }, - border: `1px solid ${theme.palette.error.main}`, }), standardInfo: ({ theme }) => ({ '& .MuiAlert-icon': { @@ -308,7 +311,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.info.background, }, - border: `1px solid ${theme.palette.info.main}`, }), standardSuccess: ({ theme }) => ({ '& .MuiAlert-icon': { @@ -317,7 +319,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.success.background, }, - border: `1px solid ${theme.palette.success.main}`, }), standardWarning: ({ theme }) => ({ '& .MuiAlert-icon': { @@ -326,7 +327,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.warning.background, }, - border: `1px solid ${theme.palette.warning.main}`, }), root: ({ theme }) => ({ color: theme.palette.text.primary, diff --git a/src/components/theme/typography.ts b/apps/web/src/components/theme/typography.ts similarity index 100% rename from src/components/theme/typography.ts rename to apps/web/src/components/theme/typography.ts diff --git a/src/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider.tsx b/apps/web/src/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider.tsx similarity index 100% rename from src/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider.tsx rename to apps/web/src/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider.tsx diff --git a/apps/web/src/components/transactions/BatchExecuteButton/index.tsx b/apps/web/src/components/transactions/BatchExecuteButton/index.tsx new file mode 100644 index 000000000..c53db8032 --- /dev/null +++ b/apps/web/src/components/transactions/BatchExecuteButton/index.tsx @@ -0,0 +1,71 @@ +import { useCallback, useContext } from 'react' +import { Button, Tooltip } from '@mui/material' +import { BatchExecuteHoverContext } from '@/components/transactions/BatchExecuteButton/BatchExecuteHoverProvider' +import { useAppSelector } from '@/store' +import { selectPendingTxs } from '@/store/pendingTxsSlice' +import useBatchedTxs from '@/hooks/useBatchedTxs' +import { ExecuteBatchFlow } from '@/components/tx-flow/flows' +import { trackEvent } from '@/services/analytics' +import { TX_LIST_EVENTS } from '@/services/analytics/events/txList' +import useWallet from '@/hooks/wallets/useWallet' +import useTxQueue from '@/hooks/useTxQueue' +import { TxModalContext } from '@/components/tx-flow' + +const BatchExecuteButton = () => { + const { setTxFlow } = useContext(TxModalContext) + const pendingTxs = useAppSelector(selectPendingTxs) + const hoverContext = useContext(BatchExecuteHoverContext) + const { page } = useTxQueue() + const batchableTransactions = useBatchedTxs(page?.results || []) + const wallet = useWallet() + + const isBatchable = batchableTransactions.length > 1 + const hasPendingTx = batchableTransactions.some((tx) => pendingTxs[tx.transaction.id]) + const isDisabled = !isBatchable || hasPendingTx || !wallet + + const handleOnMouseEnter = useCallback(() => { + hoverContext.setActiveHover(batchableTransactions.map((tx) => tx.transaction.id)) + }, [batchableTransactions, hoverContext]) + + const handleOnMouseLeave = useCallback(() => { + hoverContext.setActiveHover([]) + }, [hoverContext]) + + const handleOpenModal = () => { + trackEvent({ + ...TX_LIST_EVENTS.BATCH_EXECUTE, + label: batchableTransactions.length, + }) + + setTxFlow(, undefined, false) + } + + return ( + <> + + + + + + + ) +} + +export default BatchExecuteButton diff --git a/apps/web/src/components/transactions/BulkTxListGroup/index.tsx b/apps/web/src/components/transactions/BulkTxListGroup/index.tsx new file mode 100644 index 000000000..935c2254c --- /dev/null +++ b/apps/web/src/components/transactions/BulkTxListGroup/index.tsx @@ -0,0 +1,70 @@ +import type { ReactElement } from 'react' +import { Box, Paper, SvgIcon, Typography } from '@mui/material' +import type { Order, Transaction } from '@safe-global/safe-gateway-typescript-sdk' +import { isMultisigExecutionInfo, isSwapTransferOrderTxInfo } from '@/utils/transaction-guards' +import ExpandableTransactionItem from '@/components/transactions/TxListItem/ExpandableTransactionItem' +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 { useCurrentChain } from '@/hooks/useChains' +import { getOrderClass } from '@/features/swap/helpers/utils' + +const orderClassTitles: Record = { + limit: 'Limit order settlement', + twap: 'TWAP order settlement', + liquidity: 'Liquidity order settlement', + market: 'Swap order settlement', +} + +const getSettlementOrderTitle = (order: Order): string => { + const orderClass = getOrderClass(order) + return orderClassTitles[orderClass] || orderClassTitles['market'] +} + +const GroupedTxListItems = ({ + groupedListItems, + transactionHash, +}: { + groupedListItems: Transaction[] + transactionHash: string +}): ReactElement | null => { + const chain = useCurrentChain() + const explorerLink = chain && getBlockExplorerLink(chain, transactionHash)?.href + if (groupedListItems.length === 0) return null + let title = 'Bulk transactions' + const isSwapTransfer = isSwapTransferOrderTxInfo(groupedListItems[0].transaction.txInfo) + if (isSwapTransfer) { + title = getSettlementOrderTitle(groupedListItems[0].transaction.txInfo as Order) + } + return ( + + + + + + {title} + + {groupedListItems.length} transactions + + + + + + {groupedListItems.map((tx) => { + const nonce = isMultisigExecutionInfo(tx.transaction.executionInfo) ? tx.transaction.executionInfo.nonce : '' + return ( + + + {nonce} + + + + ) + })} + + + ) +} + +export default GroupedTxListItems diff --git a/src/components/transactions/BulkTxListGroup/styles.module.css b/apps/web/src/components/transactions/BulkTxListGroup/styles.module.css similarity index 100% rename from src/components/transactions/BulkTxListGroup/styles.module.css rename to apps/web/src/components/transactions/BulkTxListGroup/styles.module.css diff --git a/src/components/transactions/ExecuteTxButton/index.tsx b/apps/web/src/components/transactions/ExecuteTxButton/index.tsx similarity index 100% rename from src/components/transactions/ExecuteTxButton/index.tsx rename to apps/web/src/components/transactions/ExecuteTxButton/index.tsx diff --git a/src/components/transactions/GroupLabel/index.tsx b/apps/web/src/components/transactions/GroupLabel/index.tsx similarity index 100% rename from src/components/transactions/GroupLabel/index.tsx rename to apps/web/src/components/transactions/GroupLabel/index.tsx diff --git a/src/components/transactions/GroupLabel/styles.module.css b/apps/web/src/components/transactions/GroupLabel/styles.module.css similarity index 100% rename from src/components/transactions/GroupLabel/styles.module.css rename to apps/web/src/components/transactions/GroupLabel/styles.module.css diff --git a/src/components/transactions/GroupedTxListItems/ReplaceTxHoverProvider.tsx b/apps/web/src/components/transactions/GroupedTxListItems/ReplaceTxHoverProvider.tsx similarity index 100% rename from src/components/transactions/GroupedTxListItems/ReplaceTxHoverProvider.tsx rename to apps/web/src/components/transactions/GroupedTxListItems/ReplaceTxHoverProvider.tsx diff --git a/apps/web/src/components/transactions/GroupedTxListItems/index.tsx b/apps/web/src/components/transactions/GroupedTxListItems/index.tsx new file mode 100644 index 000000000..a58334385 --- /dev/null +++ b/apps/web/src/components/transactions/GroupedTxListItems/index.tsx @@ -0,0 +1,84 @@ +import type { ReactElement } from 'react' +import { useContext } from 'react' +import { Box, Paper, Typography } from '@mui/material' +import type { Transaction } from '@safe-global/safe-gateway-typescript-sdk' +import { isMultisigExecutionInfo } from '@/utils/transaction-guards' +import ExpandableTransactionItem from '@/components/transactions/TxListItem/ExpandableTransactionItem' +import css from './styles.module.css' +import { ReplaceTxHoverContext, ReplaceTxHoverProvider } from './ReplaceTxHoverProvider' +import ExternalLink from '@/components/common/ExternalLink' +import { HelpCenterArticle } from '@/config/constants' + +const Disclaimer = () => ( + + Conflicting transactions. Executing one will automatically replace the others.{' '} + + Why did this happen? + + +) + +const TxGroup = ({ groupedListItems }: { groupedListItems: Transaction[] }): ReactElement => { + const nonce = isMultisigExecutionInfo(groupedListItems[0].transaction.executionInfo) + ? groupedListItems[0].transaction.executionInfo.nonce + : undefined + + const { replacedTxIds } = useContext(ReplaceTxHoverContext) + + return ( + + + {nonce} + + + + + + + {groupedListItems.map((tx) => ( +
+ +
+ ))} +
+
+ ) +} + +const GroupedTxListItems = ({ groupedListItems }: { groupedListItems: Transaction[] }): ReactElement | null => { + if (groupedListItems.length === 0) return null + + return ( + + + + ) +} + +export default GroupedTxListItems diff --git a/src/components/transactions/GroupedTxListItems/styles.module.css b/apps/web/src/components/transactions/GroupedTxListItems/styles.module.css similarity index 100% rename from src/components/transactions/GroupedTxListItems/styles.module.css rename to apps/web/src/components/transactions/GroupedTxListItems/styles.module.css diff --git a/apps/web/src/components/transactions/HexEncodedData/HexEncodedData.test.tsx b/apps/web/src/components/transactions/HexEncodedData/HexEncodedData.test.tsx new file mode 100644 index 000000000..6aa788bcd --- /dev/null +++ b/apps/web/src/components/transactions/HexEncodedData/HexEncodedData.test.tsx @@ -0,0 +1,39 @@ +import { render } from '@/tests/test-utils' +import { HexEncodedData } from '.' + +const hexData = '0xed2ad31ed00088fc64d00c49774b2fe3fb7fd7db1c2a714700892607b9f77dc1' + +describe('HexEncodedData', () => { + it('should render the default component markup', () => { + const result = render() + const showMoreButton = result.getByTestId('show-more') + 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() + }) + + it('should not highlight the data if highlight option is false', () => { + const result = render( + , + ) + + expect(result.container.querySelector('b')).not.toBeInTheDocument() + expect(result.container).toMatchSnapshot() + }) + + it('should not cut the text in case the limit option is higher than the provided hexData', () => { + const result = render() + + expect(result.container.querySelector("button[data-testid='show-more']")).not.toBeInTheDocument() + + expect(result.container).toMatchSnapshot() + }) +}) 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 new file mode 100644 index 000000000..0cb3f80cd --- /dev/null +++ b/apps/web/src/components/transactions/HexEncodedData/__snapshots__/HexEncodedData.test.tsx.snap @@ -0,0 +1,187 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HexEncodedData should not cut the text in case the limit option is higher than the provided hexData 1`] = ` +
+
+
+

+ Data (hex-encoded) +

+
+
+
+ + + + + 0xed2ad31e + + d00088fc64d00c49774b2fe3fb7fd7db1c2a714700892607b9f77dc1 + +
+
+
+
+`; + +exports[`HexEncodedData should not highlight the data if highlight option is false 1`] = ` +
+
+
+

+ Some arbitrary data +

+
+
+
+ + + + 0x10238476... + + +
+
+
+
+`; + +exports[`HexEncodedData should render the default component markup 1`] = ` +
+
+
+

+ Data (hex-encoded) +

+
+
+
+ + + + + 0xed2ad31e + + d00088fc64... + + +
+
+
+
+`; diff --git a/apps/web/src/components/transactions/HexEncodedData/index.tsx b/apps/web/src/components/transactions/HexEncodedData/index.tsx new file mode 100644 index 000000000..fe635c5dc --- /dev/null +++ b/apps/web/src/components/transactions/HexEncodedData/index.tsx @@ -0,0 +1,56 @@ +import { shortenText } from '@/utils/formatters' +import { Box, Link, Tooltip } from '@mui/material' +import type { ReactElement } from 'react' +import { useState } from 'react' +import css from './styles.module.css' +import CopyButton from '@/components/common/CopyButton' +import FieldsGrid from '@/components/tx/FieldsGrid' + +interface Props { + hexData: string + highlightFirstBytes?: boolean + title?: string + limit?: number +} + +const FIRST_BYTES = 10 + +export const HexEncodedData = ({ hexData, title, highlightFirstBytes = true, limit = 20 }: Props): ReactElement => { + const [showTxData, setShowTxData] = useState(false) + const showExpandBtn = hexData.length > limit + + const toggleExpanded = () => { + setShowTxData((val) => !val) + } + + const firstBytes = highlightFirstBytes ? ( + + {hexData.slice(0, FIRST_BYTES)} + + ) : null + const restBytes = highlightFirstBytes ? hexData.slice(FIRST_BYTES) : hexData + + const content = ( + + + + <> + {firstBytes} + {showTxData || !showExpandBtn ? restBytes : shortenText(restBytes, limit - FIRST_BYTES)}{' '} + {showExpandBtn && ( + + Show {showTxData ? 'less' : 'more'} + + )} + + + ) + + return title ? {content} : content +} diff --git a/src/components/transactions/HexEncodedData/styles.module.css b/apps/web/src/components/transactions/HexEncodedData/styles.module.css similarity index 100% rename from src/components/transactions/HexEncodedData/styles.module.css rename to apps/web/src/components/transactions/HexEncodedData/styles.module.css diff --git a/src/components/transactions/ImitationTransactionWarning/index.tsx b/apps/web/src/components/transactions/ImitationTransactionWarning/index.tsx similarity index 100% rename from src/components/transactions/ImitationTransactionWarning/index.tsx rename to apps/web/src/components/transactions/ImitationTransactionWarning/index.tsx diff --git a/src/components/transactions/ImitationTransactionWarning/styles.module.css b/apps/web/src/components/transactions/ImitationTransactionWarning/styles.module.css similarity index 100% rename from src/components/transactions/ImitationTransactionWarning/styles.module.css rename to apps/web/src/components/transactions/ImitationTransactionWarning/styles.module.css diff --git a/src/components/transactions/InfoDetails/index.tsx b/apps/web/src/components/transactions/InfoDetails/index.tsx similarity index 100% rename from src/components/transactions/InfoDetails/index.tsx rename to apps/web/src/components/transactions/InfoDetails/index.tsx diff --git a/src/components/transactions/InfoDetails/styles.module.css b/apps/web/src/components/transactions/InfoDetails/styles.module.css similarity index 100% rename from src/components/transactions/InfoDetails/styles.module.css rename to apps/web/src/components/transactions/InfoDetails/styles.module.css diff --git a/apps/web/src/components/transactions/MaliciousTxWarning/index.tsx b/apps/web/src/components/transactions/MaliciousTxWarning/index.tsx new file mode 100644 index 000000000..a77b14d72 --- /dev/null +++ b/apps/web/src/components/transactions/MaliciousTxWarning/index.tsx @@ -0,0 +1,18 @@ +import { Tooltip, SvgIcon, Box } from '@mui/material' +import WarningIcon from '@/public/images/notifications/warning.svg' + +const MaliciousTxWarning = ({ withTooltip = true }: { withTooltip?: boolean }) => { + return withTooltip ? ( + + + + + + ) : ( + + + + ) +} + +export default MaliciousTxWarning diff --git a/src/components/transactions/RejectTxButton/index.tsx b/apps/web/src/components/transactions/RejectTxButton/index.tsx similarity index 100% rename from src/components/transactions/RejectTxButton/index.tsx rename to apps/web/src/components/transactions/RejectTxButton/index.tsx diff --git a/src/components/transactions/SafeCreationTx/index.tsx b/apps/web/src/components/transactions/SafeCreationTx/index.tsx similarity index 100% rename from src/components/transactions/SafeCreationTx/index.tsx rename to apps/web/src/components/transactions/SafeCreationTx/index.tsx diff --git a/apps/web/src/components/transactions/SafeCreationTx/styles.module.css b/apps/web/src/components/transactions/SafeCreationTx/styles.module.css new file mode 100644 index 000000000..b34c47f87 --- /dev/null +++ b/apps/web/src/components/transactions/SafeCreationTx/styles.module.css @@ -0,0 +1,11 @@ +.txCreation, +.txSummary { + padding: 20px 24px; + display: flex; + flex-direction: column; + padding-right: 60px; /* to not overlap with the share link */ +} + +.txSummary { + border-top: 1px solid var(--color-border-light); +} diff --git a/apps/web/src/components/transactions/SignTxButton/index.test.tsx b/apps/web/src/components/transactions/SignTxButton/index.test.tsx new file mode 100644 index 000000000..1ec35f5b9 --- /dev/null +++ b/apps/web/src/components/transactions/SignTxButton/index.test.tsx @@ -0,0 +1,104 @@ +import { render, waitFor } from '@/tests/test-utils' +import SignTxButton from '.' +import { executionInfoBuilder, safeTxSummaryBuilder } from '@/tests/builders/safeTx' +import { type AddressEx, DetailedExecutionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import { faker } from '@faker-js/faker' +import useWallet, { useSigner } from '@/hooks/wallets/useWallet' +import { MockEip1193Provider } from '@/tests/mocks/providers' +import { setSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import type Safe from '@safe-global/protocol-kit' +import useSafeInfo from '@/hooks/useSafeInfo' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' + +jest.mock('@/hooks/wallets/useWallet') +jest.mock('@/hooks/useSafeInfo') +jest.mock('@/hooks/useIsSafeOwner') + +describe('SignTxButton', () => { + const mockUseWallet = useWallet as jest.MockedFunction + const mockUseSigner = useSigner as jest.MockedFunction + const mockUseSafeInfo = useSafeInfo as jest.MockedFunction + const mockUseIsSafeOwner = useIsSafeOwner as jest.MockedFunction + + const testMissingSigners: AddressEx[] = [ + { + value: faker.finance.ethereumAddress(), + }, + { + value: faker.finance.ethereumAddress(), + }, + ] + const txSummary = safeTxSummaryBuilder() + .with({ + executionInfo: executionInfoBuilder() + .with({ + type: DetailedExecutionInfoType.MULTISIG, + confirmationsRequired: 3, + confirmationsSubmitted: 1, + missingSigners: testMissingSigners, + }) + .build(), + }) + .build() + + beforeEach(() => { + jest.clearAllMocks() + + const safeAddress = faker.finance.ethereumAddress() + mockUseSafeInfo.mockReturnValue({ + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .build(), + safeLoaded: true, + safeLoading: false, + }) + }) + + it('should be disabled without any wallet connected', () => { + const result = render() + expect(result.getByRole('button')).toBeDisabled() + }) + it('should be disabled with non-owner connected', () => { + mockUseWallet.mockReturnValue({ + address: faker.finance.ethereumAddress(), + chainId: '1', + label: 'MetaMask', + provider: MockEip1193Provider, + }) + + mockUseSigner.mockReturnValue({ + address: faker.finance.ethereumAddress(), + chainId: '1', + provider: MockEip1193Provider, + }) + + mockUseIsSafeOwner.mockReturnValue(false) + + const result = render() + + expect(result.getByRole('button')).toBeDisabled() + }) + + it('should be enabled with missing signer connected', async () => { + mockUseWallet.mockReturnValue({ + address: testMissingSigners[0].value, + chainId: '1', + label: 'MetaMask', + provider: MockEip1193Provider, + }) + + mockUseSigner.mockReturnValue({ + address: testMissingSigners[0].value, + chainId: '1', + provider: MockEip1193Provider, + }) + mockUseIsSafeOwner.mockReturnValue(true) + setSafeSDK({} as unknown as Safe) + const result = render() + await waitFor(() => { + expect(result.getByRole('button')).toBeEnabled() + }) + }) +}) diff --git a/apps/web/src/components/transactions/SignTxButton/index.tsx b/apps/web/src/components/transactions/SignTxButton/index.tsx new file mode 100644 index 000000000..9e10e0882 --- /dev/null +++ b/apps/web/src/components/transactions/SignTxButton/index.tsx @@ -0,0 +1,64 @@ +import useIsExpiredSwap from '@/features/swap/hooks/useIsExpiredSwap' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import type { SyntheticEvent } from 'react' +import { useContext, type ReactElement } from 'react' +import { type TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import { Button, Tooltip } from '@mui/material' + +import { isSignableBy } from '@/utils/transaction-guards' +import useWallet from '@/hooks/wallets/useWallet' +import Track from '@/components/common/Track' +import { TX_LIST_EVENTS } from '@/services/analytics/events/txList' +import CheckWallet from '@/components/common/CheckWallet' +import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import { TxModalContext } from '@/components/tx-flow' +import { ConfirmTxFlow } from '@/components/tx-flow/flows' +import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners' + +const SignTxButton = ({ + txSummary, + compact = false, +}: { + txSummary: TransactionSummary + compact?: boolean +}): ReactElement => { + const { setTxFlow } = useContext(TxModalContext) + const wallet = useWallet() + const nestedOwners = useNestedSafeOwners() + const isSafeOwner = useIsSafeOwner() + const isSignable = + isSignableBy(txSummary, wallet?.address || '') || nestedOwners?.some((owner) => isSignableBy(txSummary, owner)) + const safeSDK = useSafeSDK() + const expiredSwap = useIsExpiredSwap(txSummary.txInfo) + const isDisabled = !isSignable || !safeSDK || expiredSwap + + const onClick = (e: SyntheticEvent) => { + e.stopPropagation() + e.preventDefault() + setTxFlow(, undefined, false) + } + + return ( + + {(isOk) => ( + + + + + + + + )} + + ) +} + +export default SignTxButton diff --git a/src/components/transactions/SignTxButton/styles.module.css b/apps/web/src/components/transactions/SignTxButton/styles.module.css similarity index 100% rename from src/components/transactions/SignTxButton/styles.module.css rename to apps/web/src/components/transactions/SignTxButton/styles.module.css diff --git a/src/components/transactions/SignedMessagesHelpLink/index.tsx b/apps/web/src/components/transactions/SignedMessagesHelpLink/index.tsx similarity index 100% rename from src/components/transactions/SignedMessagesHelpLink/index.tsx rename to apps/web/src/components/transactions/SignedMessagesHelpLink/index.tsx diff --git a/src/components/transactions/SingleTx/SingleTx.test.tsx b/apps/web/src/components/transactions/SingleTx/SingleTx.test.tsx similarity index 100% rename from src/components/transactions/SingleTx/SingleTx.test.tsx rename to apps/web/src/components/transactions/SingleTx/SingleTx.test.tsx diff --git a/apps/web/src/components/transactions/SingleTx/index.tsx b/apps/web/src/components/transactions/SingleTx/index.tsx new file mode 100644 index 000000000..034630d3e --- /dev/null +++ b/apps/web/src/components/transactions/SingleTx/index.tsx @@ -0,0 +1,79 @@ +import ErrorMessage from '@/components/tx/ErrorMessage' +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 type { ReactElement } from 'react' +import { useEffect } from 'react' +import { makeTxFromDetails } from '@/utils/transactions' +import { TxListGrid } from '@/components/transactions/TxList' +import ExpandableTransactionItem, { + TransactionSkeleton, +} from '@/components/transactions/TxListItem/ExpandableTransactionItem' +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' + +const SingleTxGrid = ({ txDetails }: { txDetails: TransactionDetails }): ReactElement => { + const tx: Transaction = makeTxFromDetails(txDetails) + + // Show a label for the transaction if it's a queued transaction + const { safe } = useSafeInfo() + const nonce = isMultisigDetailedExecutionInfo(txDetails?.detailedExecutionInfo) + ? txDetails?.detailedExecutionInfo.nonce + : -1 + const label = nonce === safe.nonce ? LabelValue.Next : nonce > safe.nonce ? LabelValue.Queued : undefined + + return ( + + {label ? : null} + + + + ) +} + +const SingleTx = () => { + const router = useRouter() + const { id } = router.query + const transactionId = Array.isArray(id) ? id[0] : id + const { safe, safeAddress } = useSafeInfo() + + let { + data: txDetails, + error: txDetailsError, + refetch, + isUninitialized, + } = useGetTransactionDetailsQuery( + transactionId && safe.chainId + ? { + chainId: safe.chainId, + txId: transactionId, + } + : skipToken, + ) + + useEffect(() => { + !isUninitialized && refetch() + }, [safe.txHistoryTag, safe.txQueuedTag, safeAddress, refetch, isUninitialized]) + + if (txDetails && !sameAddress(txDetails.safeAddress, safeAddress)) { + txDetailsError = new Error('Transaction with this id was not found in this Safe Account') + } + + if (txDetailsError) { + return Failed to load transaction + } + + if (txDetails) { + return + } + + // Loading skeleton + return +} + +export default SingleTx diff --git a/src/components/transactions/TrustedToggle/TrustedToggleButton.tsx b/apps/web/src/components/transactions/TrustedToggle/TrustedToggleButton.tsx similarity index 96% rename from src/components/transactions/TrustedToggle/TrustedToggleButton.tsx rename to apps/web/src/components/transactions/TrustedToggle/TrustedToggleButton.tsx index c5bc393a8..918d64d91 100644 --- a/src/components/transactions/TrustedToggle/TrustedToggleButton.tsx +++ b/apps/web/src/components/transactions/TrustedToggle/TrustedToggleButton.tsx @@ -10,7 +10,7 @@ const _TrustedToggleButton = ({ }: { onlyTrusted: boolean setOnlyTrusted: (on: boolean) => void - hasDefaultTokenlist: boolean + hasDefaultTokenlist?: boolean }): ReactElement | null => { const onClick = () => { setOnlyTrusted(!onlyTrusted) diff --git a/apps/web/src/components/transactions/TrustedToggle/index.tsx b/apps/web/src/components/transactions/TrustedToggle/index.tsx new file mode 100644 index 000000000..dfb513fb7 --- /dev/null +++ b/apps/web/src/components/transactions/TrustedToggle/index.tsx @@ -0,0 +1,30 @@ +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' + +const useOnlyTrusted = () => { + const userSettings = useAppSelector(selectSettings) + return userSettings.hideSuspiciousTransactions || false +} + +const useHasDefaultTokenList = () => { + return useHasFeature(FEATURES.DEFAULT_TOKENLIST) +} + +const useSetOnlyTrusted = () => { + const dispatch = useAppDispatch() + return (isOn: boolean) => { + dispatch(hideSuspiciousTransactions(isOn)) + } +} + +const TrustedToggle = madProps(_TrustedToggleButton, { + onlyTrusted: useOnlyTrusted, + setOnlyTrusted: useSetOnlyTrusted, + hasDefaultTokenlist: useHasDefaultTokenList, +}) + +export default TrustedToggle diff --git a/src/components/transactions/TxConfirmations/index.tsx b/apps/web/src/components/transactions/TxConfirmations/index.tsx similarity index 100% rename from src/components/transactions/TxConfirmations/index.tsx rename to apps/web/src/components/transactions/TxConfirmations/index.tsx diff --git a/src/components/transactions/TxDateLabel/index.tsx b/apps/web/src/components/transactions/TxDateLabel/index.tsx similarity index 100% rename from src/components/transactions/TxDateLabel/index.tsx rename to apps/web/src/components/transactions/TxDateLabel/index.tsx diff --git a/src/components/transactions/TxDateLabel/styles.module.css b/apps/web/src/components/transactions/TxDateLabel/styles.module.css similarity index 100% rename from src/components/transactions/TxDateLabel/styles.module.css rename to apps/web/src/components/transactions/TxDateLabel/styles.module.css diff --git a/src/components/transactions/TxDetails/SafeTxGasForm.tsx b/apps/web/src/components/transactions/TxDetails/SafeTxGasForm.tsx similarity index 87% rename from src/components/transactions/TxDetails/SafeTxGasForm.tsx rename to apps/web/src/components/transactions/TxDetails/SafeTxGasForm.tsx index 67b4e7854..319d4fb34 100644 --- a/src/components/transactions/TxDetails/SafeTxGasForm.tsx +++ b/apps/web/src/components/transactions/TxDetails/SafeTxGasForm.tsx @@ -62,15 +62,26 @@ const SafeTxGasForm = () => { const [editing, setEditing] = useState(false) return ( - + {safeTxGas} - {isEditable && ( - setEditing(true)} fontSize="small"> + setEditing(true)} + sx={{ + fontSize: 'small', + }} + > Edit )} - {editing &&
setEditing(false)} />} ) diff --git a/apps/web/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx b/apps/web/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx new file mode 100644 index 000000000..be21c59cd --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx @@ -0,0 +1,37 @@ +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 { getDomainHash, getSafeTxMessageHash } from '@/utils/safe-hashes' + +export const SafeTxHashDataRow = ({ + safeTxHash, + safeTxData, + safeVersion, +}: { + safeTxHash: string + safeTxData?: SafeTransactionData + safeVersion: SafeVersion +}) => { + const chainId = useChainId() + const safeAddress = useSafeAddress() + + const domainHash = getDomainHash({ chainId, safeAddress, safeVersion }) + const messageHash = safeTxData ? getSafeTxMessageHash({ safeVersion, safeTxData }) : undefined + + return ( + <> + + {generateDataRowValue(safeTxHash, 'hash')} + + + {generateDataRowValue(domainHash, 'hash')} + + {messageHash && ( + + {generateDataRowValue(messageHash, 'hash')} + + )} + + ) +} diff --git a/apps/web/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx b/apps/web/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx new file mode 100644 index 000000000..0ce7dd1fe --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx @@ -0,0 +1,39 @@ +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' + +export const TxDataRow = DataRow + +export const generateDataRowValue = ( + value?: string, + type?: 'hash' | 'rawData' | 'address' | 'bytes', + hasExplorer?: boolean, + addressInfo?: AddressEx, +): ReactElement | null => { + if (value == undefined) return null + + switch (type) { + case 'hash': + case 'address': + const customAvatar = addressInfo?.logoUri + + return ( + + ) + case 'rawData': + case 'bytes': + return + default: + return {value} + } +} diff --git a/apps/web/src/components/transactions/TxDetails/Summary/index.tsx b/apps/web/src/components/transactions/TxDetails/Summary/index.tsx new file mode 100644 index 000000000..10aa2b916 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/Summary/index.tsx @@ -0,0 +1,172 @@ +import type { ReactElement } from 'react' +import React, { useMemo, useState } from 'react' +import { Link, Box } from '@mui/material' +import { generateDataRowValue, TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' +import { isCustomTxInfo, 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 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' + +interface Props { + txDetails: TransactionDetails + defaultExpanded?: boolean + hideDecodedData?: boolean +} + +const Summary = ({ txDetails, defaultExpanded = false, hideDecodedData = false }: Props): ReactElement => { + const { safe } = useSafeInfo() + const [expanded, setExpanded] = useState(defaultExpanded) + + const toggleExpanded = () => { + setExpanded((val) => !val) + } + + 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, + } + } + } + + const isCustom = isCustomTxInfo(txDetails.txInfo) + + return ( + <> + {txHash && ( + + {generateDataRowValue(txHash, 'hash', true)}{' '} + + )} + {safeTxHash && ( + + )} + + {submittedAt ? dateString(submittedAt) : null} + + + {executedAt && ( + + {dateString(executedAt)} + + )} + + {/* Advanced TxData */} + {txData && ( + <> + {!defaultExpanded && ( + + Advanced details + + )} + + {expanded && ( + + {!isCustom && !hideDecodedData && ( + + + + )} + + + {`${txData.operation} (${Operation[txData.operation].toLowerCase()})`} + + + {safeTxGas} + + + {baseGas} + + + {gasPrice} + + + {generateDataRowValue(gasToken, 'hash', true)} + + + {generateDataRowValue(refundReceiver, 'hash', true)} + + {confirmations?.map(({ signature }, index) => ( + + {generateDataRowValue(signature, 'rawData')} + + ))} + + + + {generateDataRowValue(txData.hexData, 'rawData')} + + + + )} + + )} + + ) +} + +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 && ( + + )} + + + + + {txData.baseGas} + + + {generateDataRowValue(txData.refundReceiver, 'hash', true)} + + + + + {generateDataRowValue(txData.data, 'rawData')} + + + + ) +} diff --git a/src/components/transactions/TxDetails/Summary/styles.module.css b/apps/web/src/components/transactions/TxDetails/Summary/styles.module.css similarity index 100% rename from src/components/transactions/TxDetails/Summary/styles.module.css rename to apps/web/src/components/transactions/TxDetails/Summary/styles.module.css diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodCall.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodCall.tsx new file mode 100644 index 000000000..7d78a51b9 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodCall.tsx @@ -0,0 +1,59 @@ +import NamedAddressInfo from '@/components/common/NamedAddressInfo' +import { Divider } from '@/components/tx/DecodedTx' +import { Typography } from '@mui/material' + +const MethodCall = ({ + method, + contractAddress, + contractName, + contractLogo, +}: { + method: string + contractAddress: string + contractName?: string + contractLogo?: string +}) => { + return ( + <> + + Call + + {method} + {' '} + on + + + + + + ) +} + +export default MethodCall 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 new file mode 100644 index 000000000..d5ef9bedc --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodDetails/index.tsx @@ -0,0 +1,75 @@ +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 { Value } from '@/components/transactions/TxDetails/TxData/DecodedData/ValueArray' + +type MethodDetailsProps = { + data: DataDecoded + hexData?: string + addressInfoIndex?: { + [key: string]: AddressEx + } +} + +export const MethodDetails = ({ data, hexData, addressInfoIndex }: MethodDetailsProps): ReactElement => { + if (!data.parameters?.length) { + return ( + <> + + No parameters + + + {hexData && } + + ) + } + + return ( + + + Parameters + + {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 = ( + <> + {param.name}{' '} + + {param.type} + + + ) + + return ( + + {isArrayValueParam ? ( + + ) : ( + generateDataRowValue(param.value as string, inlineType, true, addressEx) + )} + + ) + })} + + ) +} 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 new file mode 100644 index 000000000..5110562ff --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx @@ -0,0 +1,110 @@ +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' +import type { Dispatch, ReactElement, SetStateAction } from 'react' +import type { AccordionProps } from '@mui/material/Accordion/Accordion' +import SingleTxDecoded from '@/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded' +import { Button, Divider, Stack } from '@mui/material' +import css from './styles.module.css' +import classnames from 'classnames' + +type MultisendProps = { + txData?: TransactionData + compact?: boolean +} + +export const MultisendActionsHeader = ({ + setOpen, + amount, + compact = false, + title = 'All actions', +}: { + setOpen: Dispatch | undefined>> + amount: number + compact?: boolean + title?: string +}) => { + const onClickAll = (expanded: boolean) => () => { + setOpen(Array(amount).fill(expanded)) + } + + return ( +
+ {title} + }> + + + +
+ ) +} + +export const Multisend = ({ txData, compact = false }: MultisendProps): ReactElement | null => { + const [openMap, setOpenMap] = useState>() + const isOpenMapUndefined = openMap == null + + // multiSend method receives one parameter `transactions` + const multiSendTransactions = txData?.dataDecoded?.parameters?.[0].valueDecoded + + useEffect(() => { + // Initialise whether each transaction should be expanded or not + if (isOpenMapUndefined && multiSendTransactions) { + setOpenMap(multiSendTransactions.map(({ operation }) => operation === Operation.DELEGATE)) + } + }, [multiSendTransactions, isOpenMapUndefined]) + + if (!txData) return null + + // ? when can a multiSend call take no parameters? + if (!txData.dataDecoded?.parameters) { + if (txData.hexData) { + return + } + return null + } + + if (!multiSendTransactions) { + return null + } + return ( + <> + + +
+ {multiSendTransactions.map(({ dataDecoded, data, value, to, operation }, index) => { + const onChange: AccordionProps['onChange'] = (_, expanded) => { + setOpenMap((prev) => ({ + ...prev, + [index]: expanded, + })) + } + + return ( + + ) + })} +
+ + ) +} + +export default Multisend diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/styles.module.css b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/styles.module.css similarity index 100% rename from src/components/transactions/TxDetails/TxData/DecodedData/Multisend/styles.module.css rename to apps/web/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/styles.module.css 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 new file mode 100644 index 000000000..7a6f20a6f --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.test.tsx @@ -0,0 +1,108 @@ +import { render } from '@/tests/test-utils' +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' + +describe('SingleTxDecoded', () => { + it('should show native transfers', () => { + const receiver = faker.finance.ethereumAddress() + const result = render( + , + ) + + expect(result.queryByText('native transfer')).not.toBeNull() + }) + + it('should show unknown contract interactions', () => { + const unknownToken = faker.finance.ethereumAddress() + const spender = faker.finance.ethereumAddress() + const result = render( + , + ) + + expect(result.queryByText('contract interaction')).not.toBeNull() + }) + + it('should show decoded data ', () => { + const unknownToken = faker.finance.ethereumAddress() + const spender = faker.finance.ethereumAddress() + const result = render( + , + ) + + expect(result.queryAllByText('approve')).not.toHaveLength(0) + }) +}) 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 new file mode 100644 index 000000000..dfe311483 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx @@ -0,0 +1,66 @@ +import { isEmptyHexData } from '@/utils/hex' +import { type InternalTransaction, type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import type { AccordionProps } from '@mui/material/Accordion/Accordion' +import { Accordion, AccordionDetails, AccordionSummary, Stack, Typography } from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +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 { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' +import { useCurrentChain } from '@/hooks/useChains' + +type SingleTxDecodedProps = { + tx: InternalTransaction + txData: TransactionData + actionTitle: string + variant?: AccordionProps['variant'] + expanded?: boolean + onChange?: AccordionProps['onChange'] +} + +export const SingleTxDecoded = ({ tx, txData, actionTitle, variant, expanded, onChange }: SingleTxDecodedProps) => { + const chain = useCurrentChain() + const isNativeTransfer = tx.value !== '0' && (!tx.data || isEmptyHexData(tx.data)) + const method = tx.dataDecoded?.method || (isNativeTransfer ? 'native transfer' : 'contract interaction') + + const addressInfo = txData.addressInfoIndex?.[tx.to] + const name = addressInfo?.name + + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = chain && safeToL2MigrationDeployment?.networkAddresses[chain.chainId] + + const singleTxData = { + to: { value: tx.to }, + value: tx.value, + operation: tx.operation, + dataDecoded: tx.dataDecoded, + hexData: tx.data ?? undefined, + addressInfoIndex: txData.addressInfoIndex, + trustedDelegateCallTarget: sameAddress(tx.to, safeToL2MigrationAddress), // We only trusted a nested Migration + } + + return ( + + } className={accordionCss.accordion}> +
+ + {actionTitle} + + {name ? name + ': ' : ''} + {method} + +
+
+ + + + + + +
+ ) +} + +export default SingleTxDecoded diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/styles.module.css b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/styles.module.css similarity index 100% rename from src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/styles.module.css rename to apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/styles.module.css diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/ValueArray.test.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/ValueArray.test.tsx similarity index 100% rename from src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/ValueArray.test.tsx rename to apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/ValueArray.test.tsx diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/index.tsx new file mode 100644 index 000000000..9ddfd6772 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/index.tsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react' +import type { ReactElement } from 'react' +import { Typography } from '@mui/material' +import { isAddress, isArrayParameter } from '@/utils/transaction-guards' +import EthHashInfo from '@/components/common/EthHashInfo' +import { HexEncodedData } from '@/components/transactions/HexEncodedData' +import css from './styles.module.css' + +type ValueArrayProps = { + method: string + type: string + value: string | string[] + key?: string +} + +// Sometime DApps return stringified arrays, e.g. "["hello","world"]" +const parseValue = (value: ValueArrayProps['value']) => { + if (Array.isArray(value)) { + return value + } + + try { + return JSON.parse(value) + } catch { + return value + } +} + +export const Value = ({ type, value, ...props }: ValueArrayProps): ReactElement => { + const parsedValue = useMemo(() => { + return parseValue(value) + }, [value]) + + if (isArrayParameter(type) && isAddress(type) && Array.isArray(parsedValue)) { + return ( + + [ + {parsedValue.length > 0 && ( +
+ {parsedValue.map((address, index) => { + const key = `${props.key || props.method}-${index}` + if (Array.isArray(address)) { + const newProps = { + type, + ...props, + value: address, + } + return + } + return ( +
+ +
+ ) + })} +
+ )} + ] +
+ ) + } + + return +} + +const getTextValue = (value: string, key?: string) => { + return +} + +const getArrayValue = (parentId: string, value: string[], separator?: boolean) => ( + + [ +
+ {value.map((currentValue, index, values) => { + const key = `${parentId}-value-${index}` + const hasSeparator = index < values.length - 1 + + return Array.isArray(currentValue) ? ( +
{getArrayValue(key, currentValue, hasSeparator)}
+ ) : ( + getTextValue(currentValue, key) + ) + })} +
+ ]{separator ? ',' : null} +
+) + +const GenericValue = ({ method, value }: Omit): React.ReactElement => { + if (Array.isArray(value)) { + return getArrayValue(method, value) + } + + return getTextValue(value) +} diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/styles.module.css b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/styles.module.css similarity index 100% rename from src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/styles.module.css rename to apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/styles.module.css 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 new file mode 100644 index 000000000..6005fefa7 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.test.tsx @@ -0,0 +1,77 @@ +import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData/index' +import { render } from '@/tests/test-utils' + +describe('DecodedData', () => { + it('returns null if txData and toInfo are missing', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('shows an Interact with block if there is no txData but toInfo', () => { + const { getByText } = render() + + expect(getByText('Interact with:')).toBeInTheDocument() + }) + + it('shows Hex encoded data if there are no parameters', () => { + const mockTxData = { + to: { + value: '0x874E2190e6B10f5173F00E27E6D5D9F90b7664C4', + }, + value: '0', + operation: 0, + dataDecoded: { + method: 'fallback', + parameters: [], + }, + hexData: + '0x895a74850000000000000000000000000000000000000000000004bb752b4d22ab390000000000000000000000000000000000000000000000000000000000000000000b00000000000000000000000000000001f76adba2311f154678f5e5605db5c9c2', + trustedDelegateCallTarget: false, + } + + const { getByText } = render() + + expect(getByText('No parameters')).toBeInTheDocument() + expect(getByText('Data (hex-encoded)')).toBeInTheDocument() + }) + + it('does not show Hex encoded data if there is none', () => { + const mockTxData = { + to: { + value: '0x874E2190e6B10f5173F00E27E6D5D9F90b7664C4', + }, + value: '0', + operation: 0, + dataDecoded: { + method: 'mint', + parameters: [], + }, + hexData: '', + trustedDelegateCallTarget: false, + } + + const { getByText, queryByText } = render() + + expect(getByText('No parameters')).toBeInTheDocument() + expect(queryByText('Data (hex-encoded)')).not.toBeInTheDocument() + }) + + it('only shows Hex encoded data if no decodedData exists', () => { + const mockTxData = { + to: { + value: '0x874E2190e6B10f5173F00E27E6D5D9F90b7664C4', + }, + value: '0', + operation: 0, + hexData: + '0x895a74850000000000000000000000000000000000000000000004bb752b4d22ab390000000000000000000000000000000000000000000000000000000000000000000b00000000000000000000000000000001f76adba2311f154678f5e5605db5c9c2', + trustedDelegateCallTarget: false, + } + + const { getByText, queryByText } = render() + + 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 new file mode 100644 index 000000000..64103d520 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx @@ -0,0 +1,89 @@ +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 { 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' + +interface Props { + txData: TransactionDetails['txData'] + toInfo?: AddressEx +} + +export const DecodedData = ({ txData, toInfo }: Props): ReactElement | null => { + const safeAddress = useSafeAddress() + const chainInfo = useCurrentChain() + + // nothing to render + if (!txData) { + if (!toInfo) return null + + return ( + + ) + } + + const amountInWei = txData.value ?? '0' + const isDelegateCall = txData.operation === Operation.DELEGATE + 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 = ( + + ) + } else if (txData.hexData) { + // When no decoded data, display raw hex data + decodedData = + } + + return ( + + {isDelegateCall && } + + {method ? ( + + ) : ( + + )} + + {amountInWei !== '0' && ( + + )} + + {decodedData} + + ) +} + +export default DecodedData diff --git a/apps/web/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx new file mode 100644 index 000000000..60b0d53d3 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx @@ -0,0 +1,87 @@ +import DecodedTx from '@/components/tx/DecodedTx' +import useAsync from '@/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 { createTx } from '@/services/tx/tx-sender/create' +import { Safe__factory } from '@/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 useTxPreview from '@/components/tx/confirmation-views/useTxPreview' + +export const MigrationToL2TxData = ({ txDetails }: { txDetails: TransactionDetails }) => { + const readOnlyProvider = useWeb3ReadOnly() + const chain = useCurrentChain() + const { safe } = useSafeInfo() + const sdk = useSafeSDK() + // Reconstruct real tx + const [realSafeTx, realSafeTxError, realSafeTxLoading] = useAsync(async () => { + // Fetch tx receipt from backend + if (!txDetails.txHash || !chain || !sdk) { + return undefined + } + const txResult = await readOnlyProvider?.getTransaction(txDetails.txHash) + const txData = txResult?.data + + // Search for a Safe Tx to MultiSend contract + const safeInterface = Safe__factory.createInterface() + const execTransactionSelector = safeInterface.getFunction('execTransaction').selector.slice(2, 10) + const multiSendDeployment = getMultiSendContractDeployment(chain, safe.version) + const multiSendAddress = multiSendDeployment?.networkAddresses[chain.chainId] + if (!multiSendAddress) { + return undefined + } + const searchString = execTransactionSelector + const indexOfTx = txData?.indexOf(searchString) + if (indexOfTx && txData) { + // Now we need to find the tx Data + const parsedTx = safeInterface.parseTransaction({ data: `0x${txData.slice(indexOfTx)}` }) + + const execTxArgs = parsedTx?.args + if (!execTxArgs || execTxArgs.length < 10) { + return undefined + } + return createTx( + { + to: execTxArgs[0], + value: execTxArgs[1].toString(), + data: execTxArgs[2], + operation: Number(execTxArgs[3]), + safeTxGas: execTxArgs[4].toString(), + baseGas: execTxArgs[5].toString(), + gasPrice: execTxArgs[6].toString(), + gasToken: execTxArgs[7].toString(), + refundReceiver: execTxArgs[8], + }, + isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) + ? txDetails.detailedExecutionInfo.nonce + : undefined, + ) + } + }, [txDetails.txHash, txDetails.detailedExecutionInfo, chain, sdk, readOnlyProvider, safe.version]) + + const decodedDataUnavailable = !realSafeTx && !realSafeTxLoading + const [txPreview, txPreviewError] = useTxPreview(realSafeTx?.data) + + return ( + + + + {realSafeTxError ? ( + {realSafeTxError.message} + ) : txPreviewError ? ( + {txPreviewError.message} + ) : decodedDataUnavailable ? ( + + ) : ( + txPreview && + )} + + ) +} 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 new file mode 100644 index 000000000..77b45cff5 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/ExecTransaction/index.tsx @@ -0,0 +1,100 @@ +import { Safe__factory } from '@/types/contracts' +import { Skeleton } 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 ExternalLink from '@/components/common/ExternalLink' +import { NestedTransaction } from '../NestedTransaction' +import useTxPreview from '@/components/tx/confirmation-views/useTxPreview' + +const safeInterface = Safe__factory.createInterface() + +const extractTransactionData = (data: string): SafeTransaction | undefined => { + const params = data ? safeInterface.decodeFunctionData('execTransaction', data) : undefined + if (!params || params.length !== 10) { + return + } + + return { + addSignature: () => {}, + encodedSignatures: () => params[9], + getSignature: () => undefined, + data: { + to: params[0], + value: params[1], + data: params[2], + operation: params[3], + safeTxGas: params[4], + baseGas: params[5], + gasPrice: params[6], + gasToken: params[7], + refundReceiver: params[8], + nonce: -1, + }, + signatures: new Map(), + } +} + +export const ExecTransaction = ({ + data, + isConfirmationView = false, +}: { + data?: TransactionData + isConfirmationView?: boolean +}) => { + const chain = useCurrentChain() + + const childSafeTx = useMemo( + () => (data?.hexData ? extractTransactionData(data.hexData) : undefined), + [data?.hexData], + ) + + const [txPreview, error] = useTxPreview( + childSafeTx + ? { + operation: Number(childSafeTx.data.operation), + data: childSafeTx.data.data, + to: childSafeTx.data.to, + value: childSafeTx.data.value.toString(), + } + : undefined, + data?.to.value, + ) + + const decodedNestedTxDataBlock = txPreview ? ( + + ) : null + + return ( + + {decodedNestedTxDataBlock ? ( + <> + {decodedNestedTxDataBlock} + + {chain && data && ( + + Open Safe + + )} + + ) : error ? ( + Could not load details on executed transaction. + ) : ( + + )} + + ) +} diff --git a/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/NestedTransaction.tsx b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/NestedTransaction.tsx new file mode 100644 index 000000000..35f7d6ca1 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/NestedTransaction.tsx @@ -0,0 +1,38 @@ +import { Stack, SvgIcon, Typography } from '@mui/material' +import { type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' + +import { Divider } from '@/components/tx/DecodedTx' + +import NestedTransactionIcon from '@/public/images/transactions/nestedTx.svg' +import { type ReactElement } from 'react' +import MethodCall from '../DecodedData/MethodCall' +import { MethodDetails } from '../DecodedData/MethodDetails' + +export const NestedTransaction = ({ + txData, + children, + isConfirmationView = false, +}: { + txData: TransactionData | undefined + children: ReactElement + isConfirmationView?: boolean +}) => { + return ( + + {!isConfirmationView && txData?.dataDecoded && ( + <> + + + + + )} + + + + Nested transaction: + + {children} + + + ) +} 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 new file mode 100644 index 000000000..33e91b528 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation/index.tsx @@ -0,0 +1,94 @@ +import useChainId from '@/hooks/useChainId' +import { Safe__factory } from '@/types/contracts' +import { Skeleton } 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() + +export const OnChainConfirmation = ({ + data, + isConfirmationView = false, +}: { + 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 { data: nestedTxDetails, error: txDetailsError } = useGetTransactionDetailsQuery( + signedHash + ? { + chainId, + txId: signedHash, + } + : skipToken, + ) + + return ( + + {nestedTxDetails ? ( + <> + + + {(isMultiSendTxInfo(nestedTxDetails.txInfo) || isOrderTxInfo(nestedTxDetails.txInfo)) && ( + Error parsing data
}> + + + )} + + {chain && data && ( + + + Open nested transaction + + + )} + + ) : txDetailsError ? ( + Could not load details on hash to approve. + ) : ( + + )} + + ) +} diff --git a/src/components/transactions/TxDetails/TxData/Rejection/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/Rejection/index.tsx similarity index 100% rename from src/components/transactions/TxDetails/TxData/Rejection/index.tsx rename to apps/web/src/components/transactions/TxDetails/TxData/Rejection/index.tsx diff --git a/apps/web/src/components/transactions/TxDetails/TxData/SafeUpdate/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/SafeUpdate/index.tsx new file mode 100644 index 000000000..8907df4b9 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/SafeUpdate/index.tsx @@ -0,0 +1,25 @@ +import { Box, Stack } from '@mui/material' +import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import DecodedData from '../DecodedData' + +function SafeUpdate({ txData }: { txData?: TransactionData }) { + return ( + + + Safe version update + + + + + ) +} + +export default SafeUpdate diff --git a/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx new file mode 100644 index 000000000..f79a826d0 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx @@ -0,0 +1,142 @@ +import type { ComponentProps, ReactElement } from 'react' +import type { SettingsChange } from '@safe-global/safe-gateway-typescript-sdk' +import { SettingsInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import EthHashInfo from '@/components/common/EthHashInfo' +import { InfoDetails } from '@/components/transactions/InfoDetails' +import { ThresholdWarning } from '@/components/transactions/Warning' +import { UntrustedFallbackHandlerWarning } from '@/components/transactions/Warning' + +type SettingsChangeTxInfoProps = { + settingsInfo: SettingsChange['settingsInfo'] + isTxExecuted?: boolean +} + +const addressInfoProps: Pick, 'shortAddress' | 'showCopyButton' | 'hasExplorer'> = { + shortAddress: false, + showCopyButton: true, + hasExplorer: true, +} + +export const SettingsChangeTxInfo = ({ + settingsInfo, + isTxExecuted = false, +}: SettingsChangeTxInfoProps): ReactElement | null => { + if (!settingsInfo) { + return null + } + + switch (settingsInfo.type) { + case SettingsInfoType.SET_FALLBACK_HANDLER: { + return ( + <> + + + + + + ) + } + case SettingsInfoType.ADD_OWNER: + case SettingsInfoType.REMOVE_OWNER: { + const title = settingsInfo.type === SettingsInfoType.ADD_OWNER ? 'Add signer:' : 'Remove signer:' + return ( + <> + + + + + {settingsInfo.threshold} + + + + ) + } + case SettingsInfoType.SWAP_OWNER: { + return ( + + + + + + + + + ) + } + case SettingsInfoType.CHANGE_THRESHOLD: { + return ( + <> + + + {settingsInfo.threshold} + + + ) + } + case SettingsInfoType.CHANGE_IMPLEMENTATION: { + return ( + + + + ) + } + case SettingsInfoType.ENABLE_MODULE: + case SettingsInfoType.DISABLE_MODULE: { + const title = settingsInfo.type === SettingsInfoType.ENABLE_MODULE ? 'Enable module:' : 'Disable module:' + return ( + + + + ) + } + case SettingsInfoType.SET_GUARD: { + return ( + + + + ) + } + case SettingsInfoType.DELETE_GUARD: { + return + } + default: + return <> + } +} + +export default SettingsChangeTxInfo diff --git a/src/components/transactions/TxDetails/TxData/SpendingLimits/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/index.tsx similarity index 100% rename from src/components/transactions/TxDetails/TxData/SpendingLimits/index.tsx rename to apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/index.tsx diff --git a/src/components/transactions/TxDetails/TxData/SpendingLimits/styles.module.css b/apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/styles.module.css similarity index 100% rename from src/components/transactions/TxDetails/TxData/SpendingLimits/styles.module.css rename to apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/styles.module.css diff --git a/src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx b/apps/web/src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx similarity index 97% rename from src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx rename to apps/web/src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx index 3dac032a8..dd16a1943 100644 --- a/src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx @@ -68,8 +68,8 @@ const TransferActions = ({ const amount = isNativeTokenTransfer(txInfo.transferInfo) ? safeFormatUnits(txInfo.transferInfo.value, ETHER) : isERC20Transfer(txInfo.transferInfo) - ? safeFormatUnits(txInfo.transferInfo.value, txInfo.transferInfo.decimals) - : undefined + ? safeFormatUnits(txInfo.transferInfo.value, txInfo.transferInfo.decimals) + : undefined const isOutgoingTx = isOutgoingTransfer(txInfo) const canSendAgain = diff --git a/apps/web/src/components/transactions/TxDetails/TxData/Transfer/index.test.tsx b/apps/web/src/components/transactions/TxDetails/TxData/Transfer/index.test.tsx new file mode 100644 index 000000000..630e6f8f9 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/Transfer/index.test.tsx @@ -0,0 +1,280 @@ +import { render } from '@/tests/test-utils' +import TransferTxInfo from '.' +import { + TransactionInfoType, + TransactionStatus, + TransactionTokenType, + TransferDirection, +} from '@safe-global/safe-gateway-typescript-sdk' +import { faker } from '@faker-js/faker' +import { parseUnits } from 'ethers' +import { chainBuilder } from '@/tests/builders/chains' + +jest.mock('@/hooks/useChains', () => ({ + __esModule: true, + useChainId: () => '1', + useChain: () => chainBuilder().with({ chainId: '1' }).build(), + useCurrentChain: () => chainBuilder().with({ chainId: '1' }).build(), + default: () => ({ + loading: false, + error: undefined, + configs: [chainBuilder().with({ chainId: '1' }).build()], + }), +})) + +describe('TransferTxInfo', () => { + describe('should render non-malicious', () => { + it('outgoing tx', () => { + const recipient = faker.finance.ethereumAddress() + const sender = faker.finance.ethereumAddress() + const tokenAddress = faker.finance.ethereumAddress() + + const result = render( + , + ) + + expect(result.getByText('1 TST')).toBeInTheDocument() + expect(result.getByText(recipient)).toBeInTheDocument() + expect(result.queryByText('malicious', { exact: false })).toBeNull() + expect(result.queryByLabelText('This token isn’t verified on major token lists', { exact: false })).toBeNull() + }) + + it('incoming tx', () => { + const recipient = faker.finance.ethereumAddress() + const sender = faker.finance.ethereumAddress() + const tokenAddress = faker.finance.ethereumAddress() + + const result = render( + , + ) + + expect(result.getByText('12.34 TST')).toBeInTheDocument() + expect(result.getByText(sender)).toBeInTheDocument() + expect(result.queryByText('malicious', { exact: false })).toBeNull() + expect(result.queryByLabelText('This token isn’t verified on major token lists', { exact: false })).toBeNull() + }) + }) + + describe('should render untrusted', () => { + it('outgoing tx', () => { + const recipient = faker.finance.ethereumAddress() + const sender = faker.finance.ethereumAddress() + const tokenAddress = faker.finance.ethereumAddress() + + const result = render( + , + ) + + expect(result.getByText('1 TST')).toBeInTheDocument() + expect(result.getByText(recipient)).toBeInTheDocument() + expect(result.queryByText('malicious', { exact: false })).toBeNull() + expect( + result.getByLabelText('This token isn’t verified on major token lists', { exact: false }), + ).toBeInTheDocument() + }) + + it('incoming tx', () => { + const recipient = faker.finance.ethereumAddress() + const sender = faker.finance.ethereumAddress() + const tokenAddress = faker.finance.ethereumAddress() + + const result = render( + , + ) + + expect(result.getByText('12.34 TST')).toBeInTheDocument() + expect(result.getByText(sender)).toBeInTheDocument() + expect(result.queryByText('malicious', { exact: false })).toBeNull() + expect( + result.queryByLabelText('This token isn’t verified on major token lists', { exact: false }), + ).toBeInTheDocument() + }) + }) + + describe('should render imitations', () => { + it('outgoing tx', () => { + const recipient = faker.finance.ethereumAddress() + const sender = faker.finance.ethereumAddress() + const tokenAddress = faker.finance.ethereumAddress() + + const result = render( + , + ) + + expect(result.getByText('1 TST')).toBeInTheDocument() + expect(result.getByText(recipient)).toBeInTheDocument() + expect(result.getByText('malicious', { exact: false })).toBeInTheDocument() + expect(result.queryByLabelText('This token isn’t verified on major token lists', { exact: false })).toBeNull() + }) + + it('incoming tx', () => { + const recipient = faker.finance.ethereumAddress() + const sender = faker.finance.ethereumAddress() + const tokenAddress = faker.finance.ethereumAddress() + + const result = render( + , + ) + + expect(result.getByText('12.34 TST')).toBeInTheDocument() + expect(result.getByText(sender)).toBeInTheDocument() + expect(result.getByText('malicious', { exact: false })).toBeInTheDocument() + expect(result.queryByLabelText('This token isn’t verified on major token lists', { exact: false })).toBeNull() + }) + + it('untrusted and imitation tx', () => { + const recipient = faker.finance.ethereumAddress() + const sender = faker.finance.ethereumAddress() + const tokenAddress = faker.finance.ethereumAddress() + + const result = render( + , + ) + + expect(result.getByText('12.34 TST')).toBeInTheDocument() + expect(result.getByText(sender)).toBeInTheDocument() + expect(result.getByText('malicious', { exact: false })).toBeInTheDocument() + expect(result.queryByLabelText('This token isn’t verified on major token lists', { exact: false })).toBeNull() + }) + }) +}) diff --git a/src/components/transactions/TxDetails/TxData/Transfer/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/Transfer/index.tsx similarity index 100% rename from src/components/transactions/TxDetails/TxData/Transfer/index.tsx rename to apps/web/src/components/transactions/TxDetails/TxData/Transfer/index.tsx diff --git a/apps/web/src/components/transactions/TxDetails/TxData/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/index.tsx new file mode 100644 index 000000000..2811dc13c --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/index.tsx @@ -0,0 +1,115 @@ +import SettingsChangeTxInfo from '@/components/transactions/TxDetails/TxData/SettingsChange' +import type { SpendingLimitMethods } from '@/utils/transaction-guards' +import { + isExecTxData, + isOnChainConfirmationTxData, + isSafeUpdateTxData, + isStakingTxWithdrawInfo, +} from '@/utils/transaction-guards' +import { isStakingTxExitInfo } from '@/utils/transaction-guards' +import { + isCancellationTxInfo, + isCustomTxInfo, + isMigrateToL2TxData, + isMultisigDetailedExecutionInfo, + isOrderTxInfo, + isSettingsChangeTxInfo, + isSpendingLimitMethod, + isStakingTxDepositInfo, + isSupportedSpendingLimitAddress, + isTransferTxInfo, +} 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 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' +import SwapOrder from '@/features/swap/components/SwapOrder' +import StakingTxDepositDetails from '@/features/stake/components/StakingTxDepositDetails' +import StakingTxExitDetails from '@/features/stake/components/StakingTxExitDetails' +import StakingTxWithdrawDetails from '@/features/stake/components/StakingTxWithdrawDetails' +import { OnChainConfirmation } from './NestedTransaction/OnChainConfirmation' +import { ExecTransaction } from './NestedTransaction/ExecTransaction' +import SafeUpdate from './SafeUpdate' + +const TxData = ({ + txInfo, + txData, + txDetails, + trusted, + imitation, +}: { + txInfo: TransactionDetails['txInfo'] + txData: TransactionDetails['txData'] + txDetails?: TransactionDetails + trusted: boolean + imitation: boolean +}): ReactElement => { + const chainId = useChainId() + + if (isOrderTxInfo(txInfo)) { + return + } + + if (isStakingTxDepositInfo(txInfo)) { + return + } + + if (isStakingTxExitInfo(txInfo)) { + return + } + + if (isStakingTxWithdrawInfo(txInfo)) { + return + } + + if (isTransferTxInfo(txInfo)) { + return ( + + ) + } + + if (isSettingsChangeTxInfo(txInfo)) { + return + } + + if (txDetails && isCancellationTxInfo(txInfo) && isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo)) { + return + } + + if ( + isCustomTxInfo(txInfo) && + isSupportedSpendingLimitAddress(txInfo, chainId) && + isSpendingLimitMethod(txData?.dataDecoded?.method) + ) { + return + } + + if (txDetails && isMigrateToL2TxData(txData, chainId)) { + return + } + + if (isOnChainConfirmationTxData(txData)) { + return + } + + if (isExecTxData(txData)) { + return + } + + if (isSafeUpdateTxData(txData)) { + return + } + + return +} + +export default TxData diff --git a/apps/web/src/components/transactions/TxDetails/index.tsx b/apps/web/src/components/transactions/TxDetails/index.tsx new file mode 100644 index 000000000..64bec8214 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/index.tsx @@ -0,0 +1,212 @@ +import useIsExpiredSwap from '@/features/swap/hooks/useIsExpiredSwap' +import React, { type ReactElement, useEffect } from 'react' +import type { TransactionDetails, TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import { Box, CircularProgress, Typography } from '@mui/material' + +import TxSigners from '@/components/transactions/TxSigners' +import Summary from '@/components/transactions/TxDetails/Summary' +import TxData from '@/components/transactions/TxDetails/TxData' +import useChainId from '@/hooks/useChainId' +import { + isAwaitingExecution, + isOrderTxInfo, + isModuleExecutionInfo, + isMultiSendTxInfo, + isMultisigDetailedExecutionInfo, + isMultisigExecutionInfo, + isOpenSwapOrder, + isTxQueued, +} 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' +import RejectTxButton from '@/components/transactions/RejectTxButton' +import { UnsignedWarning } from '@/components/transactions/Warning' +import Multisend from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend' +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 { POLLING_INTERVAL } from '@/config/constants' +import { TxNote } from '@/features/tx-notes' +import { TxShareBlock } from '../TxShareLink/TxShareBlock' + +export const NOT_AVAILABLE = 'n/a' + +type TxDetailsProps = { + txSummary: TransactionSummary + txDetails: TransactionDetails +} + +const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement => { + const isPending = useIsPending(txSummary.id) + const hasDefaultTokenlist = useHasFeature(FEATURES.DEFAULT_TOKENLIST) + const isQueue = isTxQueued(txSummary.txStatus) + const awaitingExecution = isAwaitingExecution(txSummary.txStatus) + 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 + + // If we have no token list we always trust the transfer + const isTrustedTransfer = !hasDefaultTokenlist || isTrustedTx(txSummary) + const isImitationTransaction = isImitation(txSummary) + + 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 + } + + const expiredSwap = useIsExpiredSwap(txSummary.txInfo) + + // Module address, name and logoUri + const moduleAddress = isModuleExecutionInfo(txSummary.executionInfo) ? txSummary.executionInfo.address : undefined + const moduleAddressInfo = moduleAddress ? txDetails.txData?.addressInfoIndex?.[moduleAddress.value] : undefined + + return ( + <> + {/* /Details */} +
+
+ +
+ +
+ +
+ +
+ Error parsing data
}> + + +
+ + {/* Module information*/} + {moduleAddress && ( +
+ + + +
+ )} + +
+ {isUntrusted && !isPending && } + +
+ + {(isMultiSendTxInfo(txDetails.txInfo) || isOrderTxInfo(txDetails.txInfo)) && ( +
+ Error parsing data
}> + + +
+ )} +
+ {/* Signers */} + {(!isUnsigned || isTxFromProposer) && ( +
+ + + {isQueue && } + + {isQueue && ( + + {awaitingExecution ? : } + + + )} + + {isQueue && expiredSwap && ( + + This order has expired. Reject this transaction and try again. + + )} +
+ )} + + ) +} + +const TxDetails = ({ + txSummary, + txDetails, +}: { + txSummary: TransactionSummary + txDetails?: TransactionDetails // optional +}): ReactElement => { + const chainId = useChainId() + const { safe } = useSafeInfo() + + const { + data: txDetailsData, + error, + isLoading: loading, + refetch, + isUninitialized, + } = useGetTransactionDetailsQuery( + { chainId, txId: txSummary.id }, + { + pollingInterval: isOpenSwapOrder(txSummary.txInfo) ? POLLING_INTERVAL : undefined, + }, + ) + + useEffect(() => { + !isUninitialized && refetch() + }, [safe.txQueuedTag, refetch, txDetails, isUninitialized]) + + return ( +
+ {txDetailsData ? ( + + ) : loading ? ( +
+ +
+ ) : ( + error && ( +
+ Couldn't load the transaction details +
+ ) + )} +
+ ) +} + +export default TxDetails diff --git a/apps/web/src/components/transactions/TxDetails/styles.module.css b/apps/web/src/components/transactions/TxDetails/styles.module.css new file mode 100644 index 000000000..afd3f2c24 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/styles.module.css @@ -0,0 +1,97 @@ +.container { + display: flex; + width: 100%; + overflow-x: auto; +} + +.details { + width: 66.6%; + display: flex; + flex-direction: column; + position: relative; +} + +.shareLink { + display: flex; + justify-content: flex-end; + margin: var(--space-1); + margin-bottom: -40px; +} + +.txNote { + margin: var(--space-1) 0; + padding: 0 var(--space-2) var(--space-2); + border-bottom: 1px solid var(--color-border-light); +} + +.txNote:empty { + display: none; +} + +.loading, +.error, +.txData, +.txSummary, +.advancedDetails, +.txModule { + padding: var(--space-2); +} + +.txData { + border-bottom: 1px solid var(--color-border-light); +} + +.txSummary, +.advancedDetails { + height: 100%; +} + +.txSigners { + display: flex; + width: 33.3%; + flex-direction: column; + padding: var(--space-3); + border-left: 1px solid var(--color-border-light); +} + +.delegateCall .alert { + width: fit-content; + padding: 0 var(--space-1); +} + +.multiSend { + border-bottom: 1px solid var(--color-border-light); +} + +.buttons { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--space-1); + margin-top: var(--space-2); +} + +.buttons > * { + flex: 1; +} + +.buttons button { + width: 100%; +} + +@media (max-width: 599.95px) { + .container { + flex-direction: column; + } + + .details { + width: 100%; + } + + .txSigners { + width: 100%; + border-left: 0; + border-top: 1px solid var(--color-border-light); + } +} diff --git a/src/components/transactions/TxFilterForm/TxFilterForm.test.tsx b/apps/web/src/components/transactions/TxFilterForm/TxFilterForm.test.tsx similarity index 98% rename from src/components/transactions/TxFilterForm/TxFilterForm.test.tsx rename to apps/web/src/components/transactions/TxFilterForm/TxFilterForm.test.tsx index fc9a83e9a..32ce96e68 100644 --- a/src/components/transactions/TxFilterForm/TxFilterForm.test.tsx +++ b/apps/web/src/components/transactions/TxFilterForm/TxFilterForm.test.tsx @@ -1,7 +1,7 @@ import React from 'react' import { screen, fireEvent } from '@testing-library/react' import { act, render } from '@/tests/test-utils' -import '@testing-library/jest-dom/extend-expect' +import '@testing-library/jest-dom' import TxFilterForm from './index' import { useRouter } from 'next/router' @@ -19,7 +19,7 @@ const toggleFilter = jest.fn() const fromDate = '20/01/2021' const toDate = '20/01/2020' -const placeholder = 'dd/mm/yyyy' +const placeholder = 'DD/MM/YYYY' const errorMsgFormat = 'Invalid address format' describe('TxFilterForm Component Tests', () => { diff --git a/src/components/transactions/TxFilterForm/index.tsx b/apps/web/src/components/transactions/TxFilterForm/index.tsx similarity index 100% rename from src/components/transactions/TxFilterForm/index.tsx rename to apps/web/src/components/transactions/TxFilterForm/index.tsx diff --git a/src/components/transactions/TxFilterForm/styles.module.css b/apps/web/src/components/transactions/TxFilterForm/styles.module.css similarity index 100% rename from src/components/transactions/TxFilterForm/styles.module.css rename to apps/web/src/components/transactions/TxFilterForm/styles.module.css diff --git a/src/components/transactions/TxHeader/index.tsx b/apps/web/src/components/transactions/TxHeader/index.tsx similarity index 100% rename from src/components/transactions/TxHeader/index.tsx rename to apps/web/src/components/transactions/TxHeader/index.tsx diff --git a/apps/web/src/components/transactions/TxInfo/index.tsx b/apps/web/src/components/transactions/TxInfo/index.tsx new file mode 100644 index 000000000..3d4a9c4f4 --- /dev/null +++ b/apps/web/src/components/transactions/TxInfo/index.tsx @@ -0,0 +1,170 @@ +import { type ReactElement } from 'react' +import type { + Creation, + Custom, + MultiSend, + SettingsChange, + TransactionInfo, + Transfer, +} from '@safe-global/safe-gateway-typescript-sdk' +import { SettingsInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import TokenAmount from '@/components/common/TokenAmount' +import { + isOrderTxInfo, + isCreationTxInfo, + isCustomTxInfo, + isERC20Transfer, + isERC721Transfer, + isMultiSendTxInfo, + isNativeTokenTransfer, + isSettingsChangeTxInfo, + isTransferTxInfo, + isMigrateToL2TxInfo, + isStakingTxDepositInfo, + isStakingTxExitInfo, + isStakingTxWithdrawInfo, +} from '@/utils/transaction-guards' +import { ellipsis, maybePlural, shortenAddress } from '@/utils/formatters' +import { useCurrentChain } from '@/hooks/useChains' +import { SwapTx } from '@/features/swap/components/SwapTxInfo/SwapTx' +import StakingTxExitInfo from '@/features/stake/components/StakingTxExitInfo' +import StakingTxWithdrawInfo from '@/features/stake/components/StakingTxWithdrawInfo' +import { Box } from '@mui/material' +import css from './styles.module.css' +import StakingTxDepositInfo from '@/features/stake/components/StakingTxDepositInfo' + +export const TransferTx = ({ + info, + omitSign = false, + withLogo = true, + preciseAmount = false, +}: { + info: Transfer + omitSign?: boolean + withLogo?: boolean + preciseAmount?: boolean +}): ReactElement => { + const chainConfig = useCurrentChain() + const { nativeCurrency } = chainConfig || {} + const transfer = info.transferInfo + const direction = omitSign ? undefined : info.direction + + if (isNativeTokenTransfer(transfer)) { + return ( + + ) + } + + if (isERC20Transfer(transfer)) { + return ( + + ) + } + + if (isERC721Transfer(transfer)) { + return ( + + ) + } + + return <> +} + +const CustomTx = ({ info }: { info: Custom }): ReactElement => { + return {info.methodName} +} + +const CreationTx = ({ info }: { info: Creation }): ReactElement => { + return Created by {shortenAddress(info.creator.value)} +} + +const MultiSendTx = ({ info }: { info: MultiSend }): ReactElement => { + return ( + + {info.actionCount} {`action${maybePlural(info.actionCount)}`} + + ) +} + +const SettingsChangeTx = ({ info }: { info: SettingsChange }): ReactElement => { + if ( + info.settingsInfo?.type === SettingsInfoType.ENABLE_MODULE || + info.settingsInfo?.type === SettingsInfoType.DISABLE_MODULE + ) { + return {info.settingsInfo.module.name} + } + return <> +} + +const MigrationToL2Tx = (): ReactElement => { + return <>Migrate base contract +} + +const TxInfo = ({ info, ...rest }: { info: TransactionInfo; omitSign?: boolean; withLogo?: boolean }): ReactElement => { + if (isSettingsChangeTxInfo(info)) { + return + } + + if (isMultiSendTxInfo(info)) { + return + } + + if (isTransferTxInfo(info)) { + return + } + + if (isMigrateToL2TxInfo(info)) { + return + } + + if (isCreationTxInfo(info)) { + return + } + + if (isOrderTxInfo(info)) { + return + } + + if (isStakingTxDepositInfo(info)) { + return + } + + if (isStakingTxExitInfo(info)) { + return + } + + if (isStakingTxWithdrawInfo(info)) { + return + } + + if (isCustomTxInfo(info)) { + return + } + + return <> +} + +export default TxInfo diff --git a/apps/web/src/components/transactions/TxInfo/styles.module.css b/apps/web/src/components/transactions/TxInfo/styles.module.css new file mode 100644 index 000000000..5e6635d9b --- /dev/null +++ b/apps/web/src/components/transactions/TxInfo/styles.module.css @@ -0,0 +1,5 @@ +.txInfo { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/components/transactions/TxList/index.tsx b/apps/web/src/components/transactions/TxList/index.tsx similarity index 100% rename from src/components/transactions/TxList/index.tsx rename to apps/web/src/components/transactions/TxList/index.tsx diff --git a/src/components/transactions/TxList/styles.module.css b/apps/web/src/components/transactions/TxList/styles.module.css similarity index 100% rename from src/components/transactions/TxList/styles.module.css rename to apps/web/src/components/transactions/TxList/styles.module.css diff --git a/src/components/transactions/TxListItem/ExpandableTransactionItem.tsx b/apps/web/src/components/transactions/TxListItem/ExpandableTransactionItem.tsx similarity index 100% rename from src/components/transactions/TxListItem/ExpandableTransactionItem.tsx rename to apps/web/src/components/transactions/TxListItem/ExpandableTransactionItem.tsx diff --git a/src/components/transactions/TxListItem/index.tsx b/apps/web/src/components/transactions/TxListItem/index.tsx similarity index 100% rename from src/components/transactions/TxListItem/index.tsx rename to apps/web/src/components/transactions/TxListItem/index.tsx diff --git a/src/components/transactions/TxListItem/styles.module.css b/apps/web/src/components/transactions/TxListItem/styles.module.css similarity index 100% rename from src/components/transactions/TxListItem/styles.module.css rename to apps/web/src/components/transactions/TxListItem/styles.module.css diff --git a/src/components/transactions/TxNavigation/index.tsx b/apps/web/src/components/transactions/TxNavigation/index.tsx similarity index 100% rename from src/components/transactions/TxNavigation/index.tsx rename to apps/web/src/components/transactions/TxNavigation/index.tsx diff --git a/apps/web/src/components/transactions/TxShareLink/TxShareBlock.tsx b/apps/web/src/components/transactions/TxShareLink/TxShareBlock.tsx new file mode 100644 index 000000000..95c5a2d3f --- /dev/null +++ b/apps/web/src/components/transactions/TxShareLink/TxShareBlock.tsx @@ -0,0 +1,43 @@ +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 ( + + + } className={css.summary}> + Share link with other signers + + + 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. + + +
+ + + +
+
+ ) +} diff --git a/apps/web/src/components/transactions/TxShareLink/TxShareButton.tsx b/apps/web/src/components/transactions/TxShareLink/TxShareButton.tsx new file mode 100644 index 000000000..e9d7bf9eb --- /dev/null +++ b/apps/web/src/components/transactions/TxShareLink/TxShareButton.tsx @@ -0,0 +1,16 @@ +import { IconButton, Link, SvgIcon } from '@mui/material' +import React from 'react' +import type { ReactElement } from 'react' + +import ShareIcon from '@/public/images/common/share.svg' +import TxShareLink from '.' + +export function TxShareButton({ txId }: { txId: string }): ReactElement { + return ( + + + + + + ) +} diff --git a/apps/web/src/components/transactions/TxShareLink/index.tsx b/apps/web/src/components/transactions/TxShareLink/index.tsx new file mode 100644 index 000000000..642908272 --- /dev/null +++ b/apps/web/src/components/transactions/TxShareLink/index.tsx @@ -0,0 +1,33 @@ +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 + + return ( + + + {children} + + + ) +} + +export default TxShareLink diff --git a/apps/web/src/components/transactions/TxShareLink/styles.module.css b/apps/web/src/components/transactions/TxShareLink/styles.module.css new file mode 100644 index 000000000..17838ed67 --- /dev/null +++ b/apps/web/src/components/transactions/TxShareLink/styles.module.css @@ -0,0 +1,37 @@ +.wrapper { + border: 1px solid var(--color-border-light); + margin-top: var(--space-2); +} + +.accordion { + border: unset; +} + +.summary { + background: unset !important; +} + +.header { + font-weight: 700; +} + +.details { + padding-top: 0; + padding-bottom: 12px; +} + +.copy { + padding: var(--space-2); + padding-top: 4px; +} + +.icon { + margin-right: 4px; +} + +.button { + padding-top: 4px; + padding-bottom: 4px; + padding-left: var(--space-2); + padding-right: var(--space-2); +} diff --git a/apps/web/src/components/transactions/TxSigners/index.tsx b/apps/web/src/components/transactions/TxSigners/index.tsx new file mode 100644 index 000000000..da3ad2705 --- /dev/null +++ b/apps/web/src/components/transactions/TxSigners/index.tsx @@ -0,0 +1,260 @@ +import { useState, type ReactElement } from 'react' +import { + Box, + Link, + List, + ListItem, + ListItemIcon, + ListItemText, + type Palette, + SvgIcon, + Typography, + type ListItemIconProps, +} from '@mui/material' +import type { + AddressEx, + DetailedExecutionInfo, + TransactionDetails, + TransactionSummary, +} from '@safe-global/safe-gateway-typescript-sdk' + +import useWallet from '@/hooks/wallets/useWallet' +import useIsPending from '@/hooks/useIsPending' +import { isCancellationTxInfo, isExecutable, isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' +import EthHashInfo from '@/components/common/EthHashInfo' + +import css from './styles.module.css' +import useSafeInfo from '@/hooks/useSafeInfo' +import CreatedIcon from '@/public/images/common/created.svg' +import DotIcon from '@/public/images/common/dot.svg' +import CircleIcon from '@/public/images/common/circle.svg' +import CheckIcon from '@/public/images/common/circle-check.svg' +import CancelIcon from '@/public/images/common/cancel.svg' +import useTransactionStatus from '@/hooks/useTransactionStatus' + +// Icons +const Created = () => ( + palette.background.paper }, + }} + /> +) +const MissingConfirmation = () => +const Check = () => ( + palette.background.paper }, + }} + /> +) +const Cancel = () => +const Dot = () => + +enum StepState { + CONFIRMED = 'CONFIRMED', + ACTIVE = 'ACTIVE', + DISABLED = 'DISABLED', + ERROR = 'ERROR', +} + +const getStepColor = (state: StepState, palette: Palette): string => { + const colors: { [_key in StepState]: string } = { + [StepState.CONFIRMED]: palette.primary.main, + [StepState.ACTIVE]: palette.warning.dark, + [StepState.DISABLED]: palette.border.main, + [StepState.ERROR]: palette.error.main, + } + return colors[state] +} + +const StyledListItemIcon = ({ + $state, + ...rest +}: { + $state: StepState +} & ListItemIconProps) => ( + ({ + '.MuiSvgIcon-root': { + color: getStepColor($state, palette), + alignItems: 'center', + }, + })} + {...rest} + /> +) + +const shouldHideConfirmations = (detailedExecutionInfo?: DetailedExecutionInfo): boolean => { + if (!detailedExecutionInfo || !isMultisigDetailedExecutionInfo(detailedExecutionInfo)) { + return true + } + + const confirmationsNeeded = detailedExecutionInfo.confirmationsRequired - detailedExecutionInfo.confirmations.length + const isConfirmed = confirmationsNeeded <= 0 + + // Threshold reached or more than 3 confirmations + return isConfirmed || detailedExecutionInfo.confirmations.length > 3 +} + +type TxSignersProps = { + txDetails: TransactionDetails + txSummary: TransactionSummary + isTxFromProposer: boolean + proposer?: AddressEx +} + +export const TxSigners = ({ + txDetails, + txSummary, + isTxFromProposer, + proposer, +}: TxSignersProps): ReactElement | null => { + const { detailedExecutionInfo, txInfo, txId } = txDetails + const [hideConfirmations, setHideConfirmations] = useState(shouldHideConfirmations(detailedExecutionInfo)) + const isPending = useIsPending(txId) + const txStatus = useTransactionStatus(txSummary) + const wallet = useWallet() + const { safe } = useSafeInfo() + + const toggleHide = () => { + setHideConfirmations((prev) => !prev) + } + + if (!detailedExecutionInfo || !isMultisigDetailedExecutionInfo(detailedExecutionInfo)) { + return null + } + + const { confirmations, confirmationsRequired, executor } = detailedExecutionInfo + + // TODO: Refactor to use `isConfirmableBy` + const confirmationsCount = confirmations.length + const canExecute = wallet?.address ? isExecutable(txSummary, wallet.address, safe) : false + const confirmationsNeeded = confirmationsRequired - confirmations.length + const isConfirmed = confirmationsNeeded <= 0 || canExecute + + return ( + <> + + + {isCancellationTxInfo(txInfo) ? ( + <> + + + + On-chain rejection created + + ) : ( + <> + + + + + Created + + + )} + + + {proposer && ( + + + + + + + + + )} + + {confirmations.length > 0 && ( + + + {isConfirmed ? : } + + + Confirmations{' '} + ({`${confirmationsCount} of ${confirmationsRequired}`}) + + + )} + + {!hideConfirmations && + confirmations.map(({ signer }) => ( + + + + + + + + + ))} + {confirmations.length > 0 && ( + + + + + + + {hideConfirmations ? 'Show all' : 'Hide all'} + + + + )} + + + {executor ? : } + + + + + {executor ? ( + + + + ) : ( + !isConfirmed && ( + + ({ color: palette.border.main })}> + Can be executed once the threshold is reached + + + ) + )} + + ) +} + +export default TxSigners diff --git a/src/components/transactions/TxSigners/styles.module.css b/apps/web/src/components/transactions/TxSigners/styles.module.css similarity index 100% rename from src/components/transactions/TxSigners/styles.module.css rename to apps/web/src/components/transactions/TxSigners/styles.module.css diff --git a/src/components/transactions/TxStatusChip/index.stories.tsx b/apps/web/src/components/transactions/TxStatusChip/index.stories.tsx similarity index 100% rename from src/components/transactions/TxStatusChip/index.stories.tsx rename to apps/web/src/components/transactions/TxStatusChip/index.stories.tsx diff --git a/apps/web/src/components/transactions/TxStatusChip/index.tsx b/apps/web/src/components/transactions/TxStatusChip/index.tsx new file mode 100644 index 000000000..339236fb7 --- /dev/null +++ b/apps/web/src/components/transactions/TxStatusChip/index.tsx @@ -0,0 +1,33 @@ +import type { ReactElement, ReactNode } from 'react' +import { Typography, Chip } from '@mui/material' + +export type TxStatusChipProps = { + children: ReactNode + color?: 'primary' | 'secondary' | 'info' | 'warning' | 'success' | 'error' +} + +const TxStatusChip = ({ children, color }: TxStatusChipProps): ReactElement => { + return ( + + {children} + + } + /> + ) +} + +export default TxStatusChip diff --git a/apps/web/src/components/transactions/TxStatusLabel/index.tsx b/apps/web/src/components/transactions/TxStatusLabel/index.tsx new file mode 100644 index 000000000..f284b162b --- /dev/null +++ b/apps/web/src/components/transactions/TxStatusLabel/index.tsx @@ -0,0 +1,46 @@ +import { isCancelledSwapOrder } from '@/utils/transaction-guards' +import { CircularProgress, type Palette, Typography } from '@mui/material' +import { TransactionStatus, type TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import useIsPending from '@/hooks/useIsPending' +import useTransactionStatus from '@/hooks/useTransactionStatus' + +const getStatusColor = (tx: TransactionSummary, palette: Palette) => { + if (isCancelledSwapOrder(tx.txInfo)) { + return palette.error.main + } + + switch (tx.txStatus) { + case TransactionStatus.SUCCESS: + return palette.success.main + case TransactionStatus.FAILED: + case TransactionStatus.CANCELLED: + return palette.error.main + case TransactionStatus.AWAITING_CONFIRMATIONS: + case TransactionStatus.AWAITING_EXECUTION: + return palette.warning.main + default: + return palette.primary.main + } +} + +const TxStatusLabel = ({ tx }: { tx: TransactionSummary }) => { + const txStatusLabel = useTransactionStatus(tx) + const isPending = useIsPending(tx.id) + + return ( + getStatusColor(tx, palette) }} + data-testid="tx-status-label" + > + {isPending && } + {txStatusLabel} + + ) +} + +export default TxStatusLabel diff --git a/src/components/transactions/TxSummary/QueueActions.tsx b/apps/web/src/components/transactions/TxSummary/QueueActions.tsx similarity index 100% rename from src/components/transactions/TxSummary/QueueActions.tsx rename to apps/web/src/components/transactions/TxSummary/QueueActions.tsx diff --git a/src/components/transactions/TxSummary/index.test.tsx b/apps/web/src/components/transactions/TxSummary/index.test.tsx similarity index 100% rename from src/components/transactions/TxSummary/index.test.tsx rename to apps/web/src/components/transactions/TxSummary/index.test.tsx diff --git a/apps/web/src/components/transactions/TxSummary/index.tsx b/apps/web/src/components/transactions/TxSummary/index.tsx new file mode 100644 index 000000000..dcfe22bdb --- /dev/null +++ b/apps/web/src/components/transactions/TxSummary/index.tsx @@ -0,0 +1,104 @@ +import TxProposalChip from '@/features/proposers/components/TxProposalChip' +import StatusLabel from '@/features/swap/components/StatusLabel' +import useIsExpiredSwap from '@/features/swap/hooks/useIsExpiredSwap' +import { Box } from '@mui/material' +import type { ReactElement } from 'react' +import { type Transaction } from '@safe-global/safe-gateway-typescript-sdk' + +import css from './styles.module.css' +import DateTime from '@/components/common/DateTime' +import TxInfo from '@/components/transactions/TxInfo' +import { isMultisigExecutionInfo, isTxQueued } from '@/utils/transaction-guards' +import TxType from '@/components/transactions/TxType' +import classNames from 'classnames' +import { isImitation, isTrustedTx } from '@/utils/transactions' +import MaliciousTxWarning from '../MaliciousTxWarning' +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' + +type TxSummaryProps = { + isConflictGroup?: boolean + isBulkGroup?: boolean + item: Transaction +} + +const TxSummary = ({ item, isConflictGroup, isBulkGroup }: TxSummaryProps): ReactElement => { + const hasDefaultTokenlist = useHasFeature(FEATURES.DEFAULT_TOKENLIST) + + const tx = item.transaction + const isQueue = isTxQueued(tx.txStatus) + const nonce = isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : undefined + const isTrusted = !hasDefaultTokenlist || isTrustedTx(tx) + const isImitationTransaction = isImitation(tx) + const isPending = useIsPending(tx.id) + const executionInfo = isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo : undefined + const expiredSwap = useIsExpiredSwap(tx.txInfo) + + return ( + + {nonce !== undefined && !isConflictGroup && !isBulkGroup && ( + + {nonce} + + )} + + {(isImitationTransaction || !isTrusted) && ( + + + + )} + + + + + + + + + + + + + + {isQueue && executionInfo && ( + + {executionInfo.confirmationsSubmitted > 0 || isPending ? ( + + ) : ( + + )} + + )} + + {(!isQueue || expiredSwap || isPending) && ( + + {isQueue && expiredSwap ? : } + + )} + + {isQueue && !expiredSwap && ( + + + + )} + + ) +} + +export default TxSummary diff --git a/apps/web/src/components/transactions/TxSummary/styles.module.css b/apps/web/src/components/transactions/TxSummary/styles.module.css new file mode 100644 index 000000000..122d2ab7c --- /dev/null +++ b/apps/web/src/components/transactions/TxSummary/styles.module.css @@ -0,0 +1,85 @@ +.gridContainer { + --grid-nonce: minmax(45px, 0.25fr); + --grid-type: minmax(150px, 3fr); + --grid-info: minmax(190px, 3fr); + --grid-date: minmax(200px, 3fr); + --grid-confirmations: minmax(120px, 1fr); + + --grid-status: minmax(120px, 1fr); + --grid-actions: minmax(100px, 1fr); + + width: 100%; + display: grid; + gap: var(--space-2); + align-items: center; + white-space: nowrap; + grid-template-columns: + var(--grid-nonce) var(--grid-type) var(--grid-info) var(--grid-date) var(--grid-confirmations) + var(--grid-status) var(--grid-actions); + grid-template-areas: 'nonce type info date confirmations status actions'; +} + +.gridContainer > * { + max-width: 100%; +} + +.gridContainer.history { + grid-template-columns: var(--grid-nonce) var(--grid-type) var(--grid-info) var(--grid-date) var(--grid-status); + grid-template-areas: 'nonce type info date status'; +} + +.gridContainer.conflictGroup { + grid-template-columns: + var(--grid-type) var(--grid-info) var(--grid-date) var(--grid-confirmations) var(--grid-status) + var(--grid-actions); + grid-template-areas: 'type info date confirmations status actions'; +} + +.gridContainer.bulkGroup { + grid-template-columns: var(--grid-type) var(--grid-info) var(--grid-date) var(--grid-status); + grid-template-areas: 'type info date status'; +} + +.gridContainer.bulkGroup.untrusted { + grid-template-columns: var(--grid-nonce) minmax(200px, 2.4fr) var(--grid-info) var(--grid-date) var(--grid-status); + grid-template-areas: 'nonce type info date status'; +} + +.gridContainer.message { + grid-template-columns: var(--grid-type) var(--grid-info) var(--grid-date) var(--grid-status) var(--grid-confirmations); + grid-template-areas: 'type info date status confirmations'; +} + +.gridContainer.untrusted :not(:first-child):is(div) { + opacity: 0.4; +} + +.gridContainer .status { + margin-right: var(--space-3); + display: flex; + justify-content: flex-end; +} + +.date { + color: var(--color-text-secondary); +} + +@media (max-width: 1350px) { + .gridContainer { + gap: var(--space-1); + display: flex; + flex-wrap: wrap; + } + + .nonce { + min-width: 30px; + } + + .date { + width: 100%; + } + + .status { + margin: 0 var(--space-1); + } +} diff --git a/apps/web/src/components/transactions/TxType/index.tsx b/apps/web/src/components/transactions/TxType/index.tsx new file mode 100644 index 000000000..2c954f0b0 --- /dev/null +++ b/apps/web/src/components/transactions/TxType/index.tsx @@ -0,0 +1,34 @@ +import { useTransactionType } from '@/hooks/useTransactionType' +import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import { Box } from '@mui/material' +import css from './styles.module.css' +import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' +import { isValidElement } from 'react' + +type TxTypeProps = { + tx: TransactionSummary +} + +const TxType = ({ tx }: TxTypeProps) => { + const type = useTransactionType(tx) + + return ( + + {isValidElement(type.icon) ? ( + type.icon + ) : typeof type.icon == 'string' ? ( + + ) : null} + + {type.text} + + ) +} + +export default TxType diff --git a/src/components/transactions/TxType/styles.module.css b/apps/web/src/components/transactions/TxType/styles.module.css similarity index 100% rename from src/components/transactions/TxType/styles.module.css rename to apps/web/src/components/transactions/TxType/styles.module.css diff --git a/apps/web/src/components/transactions/Warning/index.tsx b/apps/web/src/components/transactions/Warning/index.tsx new file mode 100644 index 000000000..ea345adfa --- /dev/null +++ b/apps/web/src/components/transactions/Warning/index.tsx @@ -0,0 +1,104 @@ +import type { ReactElement } from 'react' +import { Alert, SvgIcon, Tooltip } from '@mui/material' +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 { UntrustedFallbackHandlerTxText } from '@/components/tx/confirmation-views/SettingsChange/UntrustedFallbackHandlerTxAlert' + +const Warning = ({ + datatestid, + title, + text, + severity, +}: { + datatestid?: String + title: string | ReactElement + text: string + severity: AlertColor +}): ReactElement => { + return ( + + `3px solid ${palette[severity].main} !important`, alignItems: 'center' }} + severity={severity} + icon={} + > + {text} + + + ) +} + +export const DelegateCallWarning = ({ showWarning }: { showWarning: boolean }): ReactElement => { + const severity = showWarning ? 'warning' : 'success' + return ( + + This transaction calls a smart contract that will be able to modify your Safe Account. + {showWarning && ( + <> +
+ Learn more + + )} + + } + severity={severity} + text={showWarning ? 'Unexpected delegate call' : 'Delegate call'} + /> + ) +} + +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 ( + } + severity="warning" + text="Untrusted fallback handler" + /> + ) +} + +export const ApprovalWarning = ({ approvalTxCount }: { approvalTxCount: number }): ReactElement => ( + +) + +export const ThresholdWarning = (): ReactElement => ( + +) + +export const UnsignedWarning = (): ReactElement => ( + +) diff --git a/src/components/transactions/Warning/styles.module.css b/apps/web/src/components/transactions/Warning/styles.module.css similarity index 100% rename from src/components/transactions/Warning/styles.module.css rename to apps/web/src/components/transactions/Warning/styles.module.css diff --git a/apps/web/src/components/tx-flow/SafeTxProvider.tsx b/apps/web/src/components/tx-flow/SafeTxProvider.tsx new file mode 100644 index 000000000..8d011529c --- /dev/null +++ b/apps/web/src/components/tx-flow/SafeTxProvider.tsx @@ -0,0 +1,136 @@ +import { createContext, useState, useEffect, useCallback } from 'react' +import type { Dispatch, ReactNode, SetStateAction, ReactElement } from 'react' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +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> + + safeMessage?: EIP712TypedData + setSafeMessage: Dispatch> + + safeTxError?: Error + setSafeTxError: Dispatch> + + nonce?: number + setNonce: Dispatch> + nonceNeeded?: boolean + setNonceNeeded: Dispatch> + + safeTxGas?: string + setSafeTxGas: Dispatch> + + recommendedNonce?: number + + txOrigin?: string + setTxOrigin: Dispatch> +} + +export const SafeTxContext = createContext({ + setSafeTx: () => {}, + setSafeMessage: () => {}, + setSafeTxError: () => {}, + setNonce: () => {}, + setNonceNeeded: () => {}, + setSafeTxGas: () => {}, + setTxOrigin: () => {}, +}) + +const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => { + const [safeTx, setSafeTx] = useState() + const [safeMessage, setSafeMessage] = useState() + const [safeTxError, setSafeTxError] = useState() + const [nonce, setNonce] = useState() + const [nonceNeeded, setNonceNeeded] = useState(true) + const [safeTxGas, setSafeTxGas] = useState() + const [txOrigin, setTxOrigin] = useState() + + const { safe } = useSafeInfo() + const chain = useCurrentChain() + const selectAvailableSigner = useSelectAvailableSigner() + + const setAndMigrateSafeTx: Dispatch> = 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], + ) + + // Signed txs cannot be updated + const isSigned = safeTx && safeTx.signatures.size > 0 + + // Recommended nonce and safeTxGas + const recommendedNonce = useRecommendedNonce() + const recommendedSafeTxGas = useSafeTxGas(safeTx) + + // 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) + + // Update the tx when the nonce or safeTxGas change + useEffect(() => { + if (isSigned || !safeTx?.data) return + if (safeTx.data.nonce === finalNonce && safeTx.data.safeTxGas === finalSafeTxGas) return + + setSafeTxError(undefined) + + createTx({ ...safeTx.data, safeTxGas: String(finalSafeTxGas) }, finalNonce) + .then((tx) => { + setSafeTx(tx) + }) + .catch(setSafeTxError) + }, [isSigned, finalNonce, finalSafeTxGas, safeTx?.data]) + + // Log errors + useEffect(() => { + safeTxError && logError(Errors._103, safeTxError) + }, [safeTxError]) + + return ( + + {children} + + ) +} + +export default SafeTxProvider diff --git a/src/components/tx-flow/TxInfoProvider.tsx b/apps/web/src/components/tx-flow/TxInfoProvider.tsx similarity index 93% rename from src/components/tx-flow/TxInfoProvider.tsx rename to apps/web/src/components/tx-flow/TxInfoProvider.tsx index 567087c5c..618794234 100644 --- a/src/components/tx-flow/TxInfoProvider.tsx +++ b/apps/web/src/components/tx-flow/TxInfoProvider.tsx @@ -1,4 +1,4 @@ -import { createContext } from 'react' +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' @@ -40,7 +40,7 @@ export const TxInfoContext = createContext<{ }, }) -export const TxInfoProvider = ({ children }: { children: JSX.Element }) => { +export const TxInfoProvider = ({ children }: { children: ReactElement }) => { const simulation = useSimulation() const isLoading = simulation._simulationRequestStatus === FETCH_STATUS.LOADING diff --git a/apps/web/src/components/tx-flow/common/OwnerList/index.tsx b/apps/web/src/components/tx-flow/common/OwnerList/index.tsx new file mode 100644 index 000000000..560c72671 --- /dev/null +++ b/apps/web/src/components/tx-flow/common/OwnerList/index.tsx @@ -0,0 +1,46 @@ +import { Paper, Typography, SvgIcon } from '@mui/material' +import type { PaperProps } from '@mui/material' +import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' +import type { ReactElement } from 'react' + +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' + +export function OwnerList({ + title, + owners, + sx, +}: { + owners: Array + title?: string + sx?: PaperProps['sx'] +}): ReactElement { + return ( + + + + {title ?? `New signer${maybePlural(owners)}`} + + {owners.map((newOwner) => ( + + ))} + + ) +} diff --git a/src/components/tx-flow/common/OwnerList/styles.module.css b/apps/web/src/components/tx-flow/common/OwnerList/styles.module.css similarity index 100% rename from src/components/tx-flow/common/OwnerList/styles.module.css rename to apps/web/src/components/tx-flow/common/OwnerList/styles.module.css diff --git a/src/components/tx-flow/common/TxButton.tsx b/apps/web/src/components/tx-flow/common/TxButton.tsx similarity index 100% rename from src/components/tx-flow/common/TxButton.tsx rename to apps/web/src/components/tx-flow/common/TxButton.tsx diff --git a/apps/web/src/components/tx-flow/common/TxCard/index.tsx b/apps/web/src/components/tx-flow/common/TxCard/index.tsx new file mode 100644 index 000000000..1ade97da9 --- /dev/null +++ b/apps/web/src/components/tx-flow/common/TxCard/index.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' +import { Card, CardContent } from '@mui/material' +import css from '../styles.module.css' + +const sx = { my: 2, border: 0 } + +const TxCard = ({ children }: { children: ReactNode }) => { + return ( + + + {children} + + + ) +} + +export default TxCard diff --git a/apps/web/src/components/tx-flow/common/TxLayout/index.tsx b/apps/web/src/components/tx-flow/common/TxLayout/index.tsx new file mode 100644 index 000000000..72a1e2afe --- /dev/null +++ b/apps/web/src/components/tx-flow/common/TxLayout/index.tsx @@ -0,0 +1,205 @@ +import useSafeInfo from '@/hooks/useSafeInfo' +import { type ComponentType, type ReactElement, type ReactNode, useContext, useEffect, useState } from 'react' +import { Box, Container, Grid, Typography, Button, Paper, SvgIcon, IconButton, useMediaQuery } from '@mui/material' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import { useTheme } from '@mui/material/styles' +import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import classnames from 'classnames' +import { ProgressBar } from '@/components/common/ProgressBar' +import SafeTxProvider, { SafeTxContext } from '../../SafeTxProvider' +import { TxInfoProvider } from '@/components/tx-flow/TxInfoProvider' +import TxNonce from '../TxNonce' +import TxStatusWidget from '../TxStatusWidget' +import css from './styles.module.css' +import SafeLogo from '@/public/images/logo-no-text.svg' +import { TxSecurityProvider } from '@/components/tx/security/shared/TxSecurityContext' +import ChainIndicator from '@/components/common/ChainIndicator' +import SecurityWarnings from '@/components/tx/security/SecurityWarnings' + +const TxLayoutHeader = ({ + hideNonce, + icon, + subtitle, +}: { + hideNonce: TxLayoutProps['hideNonce'] + icon: TxLayoutProps['icon'] + subtitle: TxLayoutProps['subtitle'] +}) => { + const { safe } = useSafeInfo() + const { nonceNeeded } = useContext(SafeTxContext) + + if (hideNonce && !icon && !subtitle) return null + + return ( + + + {icon && ( +
+ +
+ )} + + + {subtitle} + +
+ {!hideNonce && safe.deployed && nonceNeeded && } +
+ ) +} + +type TxLayoutProps = { + title: ReactNode + children: ReactNode + subtitle?: ReactNode + icon?: ComponentType + step?: number + txSummary?: TransactionSummary + onBack?: () => void + hideNonce?: boolean + hideProgress?: boolean + isBatch?: boolean + isReplacement?: boolean + isMessage?: boolean +} + +const TxLayout = ({ + title, + subtitle, + icon, + children, + step = 0, + txSummary, + onBack, + hideNonce = false, + hideProgress = false, + isBatch = false, + isReplacement = false, + isMessage = false, +}: TxLayoutProps): ReactElement => { + const [statusVisible, setStatusVisible] = useState(true) + + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')) + const isDesktop = useMediaQuery(theme.breakpoints.down('lg')) + + const steps = Array.isArray(children) ? children : [children] + const progress = Math.round(((step + 1) / steps.length) * 100) + + useEffect(() => { + setStatusVisible(!isSmallScreen) + }, [isSmallScreen]) + + const toggleStatus = () => { + setStatusVisible((prev) => !prev) + } + + return ( + + + + <> + {/* Header status button */} + {!isReplacement && ( + + + + )} + + + + {/* Main content */} + +
+ + {title} + + + +
+ + + {!hideProgress && ( + + + + )} + + + + +
+ {steps[step]} + + {onBack && step > 0 && ( + + )} +
+
+ + {/* Sidebar */} + {!isReplacement && ( + + {statusVisible && ( + setStatusVisible(false)} + isBatch={isBatch} + isMessage={isMessage} + /> + )} + + + + + + )} +
+
+ +
+
+
+ ) +} + +export default TxLayout 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 new file mode 100644 index 000000000..5344a505c --- /dev/null +++ b/apps/web/src/components/tx-flow/common/TxLayout/styles.module.css @@ -0,0 +1,168 @@ +.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; + /* Remove height of progress bar */ + padding: calc(var(--space-3) - 6px) var(--space-3) 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/TxNonce/index.tsx b/apps/web/src/components/tx-flow/common/TxNonce/index.tsx new file mode 100644 index 000000000..4fc8915a3 --- /dev/null +++ b/apps/web/src/components/tx-flow/common/TxNonce/index.tsx @@ -0,0 +1,296 @@ +import { memo, type ReactElement, useContext, useMemo, useState, useEffect } from 'react' +import { + Autocomplete, + Box, + IconButton, + InputAdornment, + Skeleton, + Tooltip, + Popper, + type PopperProps, + type MenuItemProps, + MenuItem, + Typography, + ListSubheader, + type ListSubheaderProps, +} from '@mui/material' +import { createFilterOptions } from '@mui/material/Autocomplete' +import { Controller, useForm } from 'react-hook-form' + +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import RotateLeftIcon from '@mui/icons-material/RotateLeft' +import NumberField from '@/components/common/NumberField' +import { useQueuedTxByNonce } from '@/hooks/useTxQueue' +import useSafeInfo from '@/hooks/useSafeInfo' +import useAddressBook from '@/hooks/useAddressBook' +import { getLatestTransactions } from '@/utils/tx-list' +import { getTransactionType } from '@/hooks/useTransactionType' +import usePreviousNonces from '@/hooks/usePreviousNonces' +import { isRejectionTx } from '@/utils/transactions' + +import css from './styles.module.css' +import classNames from 'classnames' + +const CustomPopper = function ({ + // Don't set width of Popper to that of the field + className, + ...props +}: PopperProps) { + return +} + +const NonceFormHeader = memo(function NonceFormSubheader({ children, ...props }: ListSubheaderProps) { + return ( + + + {children} + + + ) +}) + +const NonceFormOption = memo(function NonceFormOption({ + nonce, + menuItemProps, +}: { + nonce: string + menuItemProps: MenuItemProps +}): ReactElement { + const addressBook = useAddressBook() + const transactions = useQueuedTxByNonce(Number(nonce)) + + const txLabel = useMemo(() => { + const latestTransactions = getLatestTransactions(transactions) + + if (latestTransactions.length === 0) { + return + } + + const [{ transaction }] = latestTransactions + return transaction.txInfo.humanDescription || `${getTransactionType(transaction, addressBook).text} transaction` + }, [addressBook, transactions]) + + const label = txLabel || 'New transaction' + + return ( + + + {nonce} - {label} + + + ) +}) + +const getFieldMinWidth = (value: string): string => { + const MIN_CHARS = 7 + const MAX_WIDTH = '200px' + const clamped = `clamp(calc(${MIN_CHARS}ch + 6px), calc(${Math.max(MIN_CHARS, value.length)}ch + 6px), ${MAX_WIDTH})` + return clamped +} + +const filter = createFilterOptions() + +enum TxNonceFormFieldNames { + NONCE = 'nonce', +} + +enum ErrorMessages { + NONCE_MUST_BE_NUMBER = 'Nonce must be a number', + NONCE_TOO_LOW = "Nonce can't be lower than %%nonce%%", + NONCE_TOO_HIGH = 'Nonce is too high', + NONCE_TOO_FAR = 'Nonce is much higher than the current nonce', + NONCE_GT_RECOMMENDED = 'Nonce is higher than the recommended nonce', + NONCE_MUST_BE_INTEGER = "Nonce can't contain decimals", +} + +const MAX_NONCE_DIFFERENCE = 100 + +const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNonce: string }) => { + const { safeTx, setNonce } = useContext(SafeTxContext) + const previousNonces = usePreviousNonces().map((nonce) => nonce.toString()) + const { safe } = useSafeInfo() + const [warning, setWarning] = useState('') + + const showRecommendedNonceButton = recommendedNonce !== nonce + const isEditable = !safeTx || safeTx?.signatures.size === 0 + const readOnly = !isEditable || isRejectionTx(safeTx) + + const formMethods = useForm({ + defaultValues: { + [TxNonceFormFieldNames.NONCE]: nonce, + }, + mode: 'all', + values: { + [TxNonceFormFieldNames.NONCE]: nonce, + }, + }) + + const resetNonce = () => { + formMethods.setValue(TxNonceFormFieldNames.NONCE, recommendedNonce) + } + + useEffect(() => { + let message = '' + // Warnings + if (Number(nonce) > Number(recommendedNonce)) { + message = ErrorMessages.NONCE_GT_RECOMMENDED + } + + if (Number(nonce) >= safe.nonce + MAX_NONCE_DIFFERENCE) { + message = ErrorMessages.NONCE_TOO_FAR + } + + setWarning(message) + }, [nonce, recommendedNonce, safe.nonce]) + + return ( + { + // nonce is always valid so no need to validate if the input is the same + if (value === nonce) return + + const newNonce = Number(value) + + if (isNaN(newNonce)) { + return ErrorMessages.NONCE_MUST_BE_NUMBER + } + + if (newNonce < safe.nonce) { + return ErrorMessages.NONCE_TOO_LOW.replace('%%nonce%%', safe.nonce.toString()) + } + + if (newNonce >= Number.MAX_SAFE_INTEGER) { + return ErrorMessages.NONCE_TOO_HIGH + } + + if (!Number.isInteger(newNonce)) { + return ErrorMessages.NONCE_MUST_BE_INTEGER + } + + // Update context with valid nonce + setNonce(newNonce) + }, + }} + render={({ field, fieldState }) => { + if (readOnly) { + return ( + + {nonce} + + ) + } + + return ( + field.onChange(value)} + onInputChange={(_, value) => field.onChange(value)} + onBlur={() => { + field.onBlur() + + if (fieldState.error) { + formMethods.setValue(field.name, recommendedNonce.toString()) + } + }} + options={[recommendedNonce, ...previousNonces]} + getOptionLabel={(option) => option.toString()} + filterOptions={(options, params) => { + const filtered = filter(options, params) + + // Prevent segments from showing recommended, e.g. if recommended is 250, don't show for 2, 5 or 25 + const shouldShow = !recommendedNonce.includes(params.inputValue) + const isQueued = options.some((option) => params.inputValue === option) + + if (params.inputValue !== '' && !isQueued && shouldShow) { + filtered.push(recommendedNonce) + } + + return filtered + }} + renderOption={(props, option) => { + const isRecommendedNonce = option === recommendedNonce + const isInitialPreviousNonce = option === previousNonces[0] + + const { key, ...rest } = props + + return ( +
+ {isRecommendedNonce && Recommended nonce} + {isInitialPreviousNonce && Replace existing} + +
+ ) + }} + disableClearable + componentsProps={{ + paper: { + elevation: 2, + }, + }} + renderInput={(params) => { + return ( + + + + + + + + + ) : null, + }} + className={classNames([ + css.input, + { + [css.withAdornment]: showRecommendedNonceButton, + }, + ])} + sx={{ + minWidth: getFieldMinWidth(field.value), + }} + /> + + ) + }} + PopperComponent={CustomPopper} + /> + ) + }} + /> + ) +} + +const skeletonMinWidth = getFieldMinWidth('') + +const TxNonce = () => { + const { nonce, recommendedNonce } = useContext(SafeTxContext) + + return ( + + Nonce{' '} + + # + + {nonce === undefined || recommendedNonce === undefined ? ( + + ) : ( + + )} + + ) +} + +export default TxNonce diff --git a/src/components/tx-flow/common/TxNonce/styles.module.css b/apps/web/src/components/tx-flow/common/TxNonce/styles.module.css similarity index 100% rename from src/components/tx-flow/common/TxNonce/styles.module.css rename to apps/web/src/components/tx-flow/common/TxNonce/styles.module.css diff --git a/apps/web/src/components/tx-flow/common/TxStatusWidget/index.tsx b/apps/web/src/components/tx-flow/common/TxStatusWidget/index.tsx new file mode 100644 index 000000000..11847b6ee --- /dev/null +++ b/apps/web/src/components/tx-flow/common/TxStatusWidget/index.tsx @@ -0,0 +1,112 @@ +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 useSafeInfo from '@/hooks/useSafeInfo' +import { isMultisigExecutionInfo, isSignableBy, isConfirmableBy } from '@/utils/transaction-guards' +import classnames from 'classnames' +import css from './styles.module.css' +import CloseIcon from '@mui/icons-material/Close' +import useWallet from '@/hooks/wallets/useWallet' +import SafeLogo from '@/public/images/logo-no-text.svg' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useIsWalletProposer } from '@/hooks/useProposers' + +const TxStatusWidget = ({ + step, + txSummary, + handleClose, + isBatch = false, + isMessage = false, +}: { + step: number + txSummary?: TransactionSummary + handleClose: () => void + isBatch?: boolean + isMessage?: boolean +}) => { + const wallet = useWallet() + const { safe } = useSafeInfo() + const { nonceNeeded } = useContext(SafeTxContext) + const { threshold } = safe + const isSafeOwner = useIsSafeOwner() + const isProposer = useIsWalletProposer() + const isProposing = isProposer && !isSafeOwner + + const { executionInfo = undefined } = txSummary || {} + const { confirmationsSubmitted = 0 } = isMultisigExecutionInfo(executionInfo) ? executionInfo : {} + + const canConfirm = txSummary + ? isConfirmableBy(txSummary, wallet?.address || '') + : safe.threshold === 1 && !isProposing + + const canSign = txSummary ? isSignableBy(txSummary, wallet?.address || '') : !isProposing + + return ( + +
+ + + {isMessage ? 'Message' : 'Transaction'} status + + + + + +
+ + + +
+ + + + + + + + {isBatch ? 'Queue transactions' : 'Create'} + + + + + + + + + + {isBatch ? ( + 'Create batch' + ) : !nonceNeeded ? ( + 'Confirmed' + ) : isMessage ? ( + 'Collect signatures' + ) : ( + <> + Confirmed ({confirmationsSubmitted} of {threshold}) + {canSign && ( + + +1 + + )} + + )} + + + + + + + + + {isMessage ? 'Done' : 'Execute'} + + +
+
+ ) +} + +export default TxStatusWidget diff --git a/src/components/tx-flow/common/TxStatusWidget/styles.module.css b/apps/web/src/components/tx-flow/common/TxStatusWidget/styles.module.css similarity index 100% rename from src/components/tx-flow/common/TxStatusWidget/styles.module.css rename to apps/web/src/components/tx-flow/common/TxStatusWidget/styles.module.css diff --git a/src/components/tx-flow/common/constants.ts b/apps/web/src/components/tx-flow/common/constants.ts similarity index 100% rename from src/components/tx-flow/common/constants.ts rename to apps/web/src/components/tx-flow/common/constants.ts diff --git a/src/components/tx-flow/common/styles.module.css b/apps/web/src/components/tx-flow/common/styles.module.css similarity index 100% rename from src/components/tx-flow/common/styles.module.css rename to apps/web/src/components/tx-flow/common/styles.module.css diff --git a/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx b/apps/web/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx similarity index 83% rename from src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx rename to apps/web/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx index 2fa4aa625..916c0629c 100644 --- a/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx +++ b/apps/web/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx @@ -27,6 +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' type FormData = Pick @@ -83,12 +84,27 @@ export const ChooseOwner = ({ {params.removedOwner && ( <> - + {params.removedOwner && 'Review the signer you want to replace in the active Safe Account, then specify the new signer you want to replace it with:'} - - + + Current signer @@ -125,7 +141,13 @@ export const ChooseOwner = ({ {mode === ChooseOwnerMode.ADD && ( - + Threshold @@ -143,11 +165,24 @@ export const ChooseOwner = ({ - + Any transaction requires the confirmation of: - + - out of {newNumberOfOwners} signer(s) + + out of {newNumberOfOwners} signer{maybePlural(newNumberOfOwners)} + diff --git a/apps/web/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx b/apps/web/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx new file mode 100644 index 000000000..1ef43c4f6 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx @@ -0,0 +1,60 @@ +import { useCurrentChain } from '@/hooks/useChains' +import { useContext, useEffect } 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' +import { useAppDispatch } from '@/store' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' +import { SafeTxContext } from '../../SafeTxProvider' +import type { AddOwnerFlowProps } from '.' +import type { ReplaceOwnerFlowProps } from '../ReplaceOwner' +import { checksumAddress } from '@/utils/addresses' +import { SettingsChangeContext } from './context' + +export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwnerFlowProps }) => { + const dispatch = useAppDispatch() + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + const { safe } = useSafeInfo() + const { chainId } = safe + const chain = useCurrentChain() + const { newOwner, removedOwner, threshold } = params + + useEffect(() => { + if (!chain) return + + const promise = removedOwner + ? createSwapOwnerTx(chain, safe.deployed, { + newOwnerAddress: checksumAddress(newOwner.address), + oldOwnerAddress: checksumAddress(removedOwner.address), + }) + : createAddOwnerTx(chain, safe.deployed, { + ownerAddress: checksumAddress(newOwner.address), + threshold, + }) + + promise.then(setSafeTx).catch(setSafeTxError) + }, [removedOwner, newOwner, threshold, setSafeTx, setSafeTxError, chain, safe.deployed]) + + const addAddressBookEntryAndSubmit = () => { + if (typeof newOwner.name !== 'undefined') { + dispatch( + upsertAddressBookEntries({ + chainIds: [chainId], + address: newOwner.address, + name: newOwner.name, + }), + ) + } + + trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: safe.threshold }) + trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) + } + + return ( + + + + ) +} diff --git a/apps/web/src/components/tx-flow/flows/AddOwner/context.ts b/apps/web/src/components/tx-flow/flows/AddOwner/context.ts new file mode 100644 index 000000000..ce829bcb8 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/AddOwner/context.ts @@ -0,0 +1,7 @@ +import { type Context, createContext } from 'react' +import { type AddOwnerFlowProps } from '.' +import { type ReplaceOwnerFlowProps } from '../ReplaceOwner' + +type SettingsChange = Context + +export const SettingsChangeContext: SettingsChange = createContext({} as AddOwnerFlowProps | ReplaceOwnerFlowProps) diff --git a/src/components/tx-flow/flows/AddOwner/index.tsx b/apps/web/src/components/tx-flow/flows/AddOwner/index.tsx similarity index 100% rename from src/components/tx-flow/flows/AddOwner/index.tsx rename to apps/web/src/components/tx-flow/flows/AddOwner/index.tsx diff --git a/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx b/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx similarity index 100% rename from src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx rename to apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx diff --git a/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx b/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx similarity index 100% rename from src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx rename to apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx diff --git a/src/components/tx-flow/flows/CancelRecovery/index.tsx b/apps/web/src/components/tx-flow/flows/CancelRecovery/index.tsx similarity index 100% rename from src/components/tx-flow/flows/CancelRecovery/index.tsx rename to apps/web/src/components/tx-flow/flows/CancelRecovery/index.tsx diff --git a/src/components/tx-flow/flows/CancelRecovery/styles.module.css b/apps/web/src/components/tx-flow/flows/CancelRecovery/styles.module.css similarity index 100% rename from src/components/tx-flow/flows/CancelRecovery/styles.module.css rename to apps/web/src/components/tx-flow/flows/CancelRecovery/styles.module.css diff --git a/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx b/apps/web/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx similarity index 77% rename from src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx rename to apps/web/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx index e88fa29db..e54990b9b 100644 --- a/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx +++ b/apps/web/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx @@ -21,6 +21,7 @@ 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' export const ChooseThreshold = ({ params, @@ -41,7 +42,12 @@ export const ChooseThreshold = ({ return (
- + Threshold @@ -61,9 +67,12 @@ export const ChooseThreshold = ({ Any transaction will require the confirmation of:
- - + + {safe.owners.map((_, idx) => ( - + {idx + 1} ))} - - out of {safe.owners.length} signer(s) + + out of {safe.owners.length} signer{maybePlural(safe.owners)} + - {isError ? ( - + {fieldState.error?.message} ) : ( - + {fieldState.isDirty ? 'Previous policy was ' : 'Current policy is '} {safe.threshold} out of {safe.owners.length} @@ -118,6 +143,7 @@ export const ChooseThreshold = ({ + )} + + + +
+ ) +} + +export default RecoveryAttemptReview diff --git a/apps/web/src/components/tx-flow/flows/RecoveryAttempt/index.tsx b/apps/web/src/components/tx-flow/flows/RecoveryAttempt/index.tsx new file mode 100644 index 000000000..eda57215d --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/RecoveryAttempt/index.tsx @@ -0,0 +1,14 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import SaveAddressIcon from '@/public/images/common/save-address.svg' +import RecoveryAttemptReview from './RecoveryAttemptReview' +import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' + +const RecoveryAttemptFlow = ({ item }: { item: RecoveryQueueItem }) => { + return ( + + + + ) +} + +export default RecoveryAttemptFlow diff --git a/src/components/tx-flow/flows/RejectTx/RejectTx.tsx b/apps/web/src/components/tx-flow/flows/RejectTx/RejectTx.tsx similarity index 95% rename from src/components/tx-flow/flows/RejectTx/RejectTx.tsx rename to apps/web/src/components/tx-flow/flows/RejectTx/RejectTx.tsx index 654e46156..5e47cc5b5 100644 --- a/src/components/tx-flow/flows/RejectTx/RejectTx.tsx +++ b/apps/web/src/components/tx-flow/flows/RejectTx/RejectTx.tsx @@ -19,7 +19,7 @@ const RejectTx = ({ txNonce }: RejectTxProps): ReactElement => { }, [txNonce, setNonce, setSafeTx, setSafeTxError]) return ( - + To reject the transaction, a separate rejection transaction will be created to replace the original one. diff --git a/src/components/tx-flow/flows/RejectTx/index.tsx b/apps/web/src/components/tx-flow/flows/RejectTx/index.tsx similarity index 100% rename from src/components/tx-flow/flows/RejectTx/index.tsx rename to apps/web/src/components/tx-flow/flows/RejectTx/index.tsx diff --git a/src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx b/apps/web/src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx similarity index 100% rename from src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx rename to apps/web/src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx diff --git a/src/components/tx-flow/flows/RemoveGuard/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveGuard/index.tsx similarity index 100% rename from src/components/tx-flow/flows/RemoveGuard/index.tsx rename to apps/web/src/components/tx-flow/flows/RemoveGuard/index.tsx diff --git a/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx b/apps/web/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx similarity index 90% rename from src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx rename to apps/web/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx index 9df4ced25..e45e50994 100644 --- a/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx @@ -27,7 +27,13 @@ export const ReviewRemoveModule = ({ params }: { params: RemoveModuleFlowProps } return ( - + Module @@ -35,7 +41,11 @@ export const ReviewRemoveModule = ({ params }: { params: RemoveModuleFlowProps }
- + 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. diff --git a/src/components/tx-flow/flows/RemoveModule/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveModule/index.tsx similarity index 100% rename from src/components/tx-flow/flows/RemoveModule/index.tsx rename to apps/web/src/components/tx-flow/flows/RemoveModule/index.tsx diff --git a/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx b/apps/web/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx similarity index 91% rename from src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx rename to apps/web/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx index 4a0c97b78..cf3d94c0d 100644 --- a/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx @@ -14,6 +14,8 @@ import EthHashInfo from '@/components/common/EthHashInfo' import commonCss from '@/components/tx-flow/common/styles.module.css' import { checksumAddress } from '@/utils/addresses' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' +import { maybePlural } from '@/utils/formatters' export const ReviewRemoveOwner = ({ params }: { params: RemoveOwnerFlowProps }): ReactElement => { const addressBook = useAddressBook() @@ -49,13 +51,15 @@ export const ReviewRemoveOwner = ({ params }: { params: RemoveOwnerFlowProps }): hasExplorer /> + + Any transaction requires the confirmation of: - {threshold} out of {newOwnerLength} signers + {threshold} out of {newOwnerLength} signer{maybePlural(newOwnerLength)} diff --git a/apps/web/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx b/apps/web/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx new file mode 100644 index 000000000..18de9819f --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react' +import { Button, Box, CardActions, Divider, Grid, MenuItem, Select, Typography, SvgIcon, Tooltip } from '@mui/material' +import type { ReactElement, SyntheticEvent } from 'react' +import type { SelectChangeEvent } from '@mui/material' + +import EthHashInfo from '@/components/common/EthHashInfo' +import useSafeInfo from '@/hooks/useSafeInfo' +import TxCard from '../../common/TxCard' +import InfoIcon from '@/public/images/notifications/info.svg' +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' + +export const SetThreshold = ({ + params, + onSubmit, +}: { + params: RemoveOwnerFlowProps + onSubmit: (data: RemoveOwnerFlowProps) => void +}): ReactElement => { + const { safe } = useSafeInfo() + const [selectedThreshold, setSelectedThreshold] = useState(params.threshold ?? 1) + + const handleChange = (event: SelectChangeEvent) => { + setSelectedThreshold(parseInt(event.target.value.toString())) + } + + const onSubmitHandler = (e: SyntheticEvent) => { + e.preventDefault() + onSubmit({ ...params, threshold: selectedThreshold }) + } + + const newNumberOfOwners = safe ? safe.owners.length - 1 : 1 + + return ( + +
+ + + Review the signer you want to remove from the active Safe Account: + + {/* TODO: Update the EthHashInfo style from the replace owner PR */} + + + + + + + + Threshold + + + + + + + Any transaction requires the confirmation of: + + + + + + + out of {newNumberOfOwners} signer{maybePlural(newNumberOfOwners)} + + + + + + + + + + + +
+ ) +} diff --git a/src/components/tx-flow/flows/RemoveOwner/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveOwner/index.tsx similarity index 100% rename from src/components/tx-flow/flows/RemoveOwner/index.tsx rename to apps/web/src/components/tx-flow/flows/RemoveOwner/index.tsx diff --git a/src/components/tx-flow/flows/RemoveOwner/styles.module.css b/apps/web/src/components/tx-flow/flows/RemoveOwner/styles.module.css similarity index 100% rename from src/components/tx-flow/flows/RemoveOwner/styles.module.css rename to apps/web/src/components/tx-flow/flows/RemoveOwner/styles.module.css diff --git a/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx b/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx similarity index 100% rename from src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx rename to apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx diff --git a/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx b/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx similarity index 100% rename from src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx rename to apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx diff --git a/src/components/tx-flow/flows/RemoveRecovery/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveRecovery/index.tsx similarity index 100% rename from src/components/tx-flow/flows/RemoveRecovery/index.tsx rename to apps/web/src/components/tx-flow/flows/RemoveRecovery/index.tsx diff --git a/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimit.tsx b/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimit.tsx new file mode 100644 index 000000000..4ca459a05 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimit.tsx @@ -0,0 +1,110 @@ +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 ( + + {token && } + + + + Beneficiary + + + + + + + + + + Reset time + + + + + + + + ) +} diff --git a/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx similarity index 100% rename from src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx rename to apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx diff --git a/src/components/tx-flow/flows/ReplaceOwner/index.tsx b/apps/web/src/components/tx-flow/flows/ReplaceOwner/index.tsx similarity index 100% rename from src/components/tx-flow/flows/ReplaceOwner/index.tsx rename to apps/web/src/components/tx-flow/flows/ReplaceOwner/index.tsx diff --git a/src/components/tx-flow/flows/ReplaceTx/DeleteTxModal.tsx b/apps/web/src/components/tx-flow/flows/ReplaceTx/DeleteTxModal.tsx similarity index 82% rename from src/components/tx-flow/flows/ReplaceTx/DeleteTxModal.tsx rename to apps/web/src/components/tx-flow/flows/ReplaceTx/DeleteTxModal.tsx index 0bfa98522..7e89c09ef 100644 --- a/src/components/tx-flow/flows/ReplaceTx/DeleteTxModal.tsx +++ b/apps/web/src/components/tx-flow/flows/ReplaceTx/DeleteTxModal.tsx @@ -27,6 +27,8 @@ import { txDispatch, TxEvent } from '@/services/tx/txEvents' import { REJECT_TX_EVENTS } from '@/services/analytics/events/reject-tx' import { trackEvent } from '@/services/analytics' import { isWalletRejection } from '@/utils/wallets' +import CheckWallet from '@/components/common/CheckWallet' +import ChainSwitcher from '@/components/common/ChainSwitcher' type DeleteTxModalProps = { safeTxHash: string @@ -37,7 +39,14 @@ type DeleteTxModalProps = { safeAddress: ReturnType } -const _DeleteTxModal = ({ safeTxHash, onSuccess, onClose, wallet, safeAddress, chainId }: DeleteTxModalProps) => { +const InternalDeleteTxModal = ({ + safeTxHash, + onSuccess, + onClose, + wallet, + safeAddress, + chainId, +}: DeleteTxModalProps) => { const [error, setError] = useState() const [isLoading, setIsLoading] = useState(false) @@ -115,6 +124,10 @@ const _DeleteTxModal = ({ safeTxHash, onSuccess, onClose, wallet, safeAddress, c related to deleting a transaction off-chain.
+ + + + {error && ( Error deleting transaction @@ -129,23 +142,27 @@ const _DeleteTxModal = ({ safeTxHash, onSuccess, onClose, wallet, safeAddress, c Keep it - + + {(isOk) => ( + + )} + ) } -const DeleteTxModal = madProps(_DeleteTxModal, { +const DeleteTxModal = madProps(InternalDeleteTxModal, { wallet: useWallet, chainId: useChainId, safeAddress: useSafeAddress, diff --git a/apps/web/src/components/tx-flow/flows/ReplaceTx/index.tsx b/apps/web/src/components/tx-flow/flows/ReplaceTx/index.tsx new file mode 100644 index 000000000..cfeb96908 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/ReplaceTx/index.tsx @@ -0,0 +1,185 @@ +import { useContext, useState } from 'react' +import { type NextRouter, useRouter } from 'next/router' +import { Box, Tooltip, Typography } from '@mui/material' +import DeleteIcon from '@/public/images/common/delete.svg' +import CancelIcon from '@/public/images/common/cancel.svg' +import ReplaceTxIcon from '@/public/images/transactions/replace-tx.svg' +import CachedIcon from '@mui/icons-material/Cached' +import { useQueuedTxByNonce } from '@/hooks/useTxQueue' +import { isCustomTxInfo } from '@/utils/transaction-guards' + +import css from './styles.module.css' +import { TxModalContext } from '../..' +import TokenTransferFlow from '../TokenTransfer' +import RejectTx from '../RejectTx' +import TxLayout from '@/components/tx-flow/common/TxLayout' +import TxCard from '@/components/tx-flow/common/TxCard' +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 { 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' + +const goToQueue = (router: NextRouter) => { + if (router.pathname === AppRoutes.transactions.tx) { + router.push({ + pathname: AppRoutes.transactions.queue, + query: { safe: router.query.safe }, + }) + } +} + +/** + * To avoid nonce gaps in the queue, we allow deleting the last transaction in the queue or duplicates. + * The recommended nonce is used to calculate the last transaction in the queue. + */ +const useIsNonceDeletable = (txNonce: number) => { + const queuedTxsByNonce = useQueuedTxByNonce(txNonce) + const recommendedNonce = useRecommendedNonce() || 0 + const duplicateCount = queuedTxsByNonce?.length || 0 + return duplicateCount > 1 || txNonce === recommendedNonce - 1 +} + +const DeleteTxButton = ({ + safeTxHash, + txNonce, + onSuccess, +}: { + safeTxHash: string + txNonce: number + onSuccess: () => void +}) => { + const router = useRouter() + const isDeletable = useIsNonceDeletable(txNonce) + const [isDeleting, setIsDeleting] = useState(false) + + const onDeleteSuccess = () => { + setIsDeleting(false) + goToQueue(router) + onSuccess() + } + const onDeleteClose = () => setIsDeleting(false) + + return ( + <> + + or + + + + Don’t want to have this transaction anymore? Remove it permanently from the queue. + + + + + + setIsDeleting(true)} + title="Delete from the queue" + description="Remove this transaction from the off-chain queue" + disabled={!isDeletable} + /> + + + + + {safeTxHash && isDeleting && ( + + )} + + ) +} + +const ReplaceTxMenu = ({ + txNonce, + safeTxHash, + proposer, +}: { + txNonce: number + safeTxHash?: string + proposer?: string +}) => { + const wallet = useWallet() + const { setTxFlow } = useContext(TxModalContext) + const queuedTxsByNonce = useQueuedTxByNonce(txNonce) + const canCancel = !queuedTxsByNonce?.some( + (item) => isCustomTxInfo(item.transaction.txInfo) && item.transaction.txInfo.isCancellation, + ) + + const isDeleteEnabled = useHasFeature(FEATURES.DELETE_TX) + const canDelete = safeTxHash && isDeleteEnabled && proposer && wallet && sameAddress(wallet.address, proposer) + + return ( + + + + + + + + You can replace or reject this transaction on-chain. It requires gas fees and your signature.{' '} + + + Read more + + + + + + + setTxFlow()} + title="Replace with another transaction" + description="Propose a new transaction with the same nonce to overwrite this one" + chip="Recommended" + /> + + + + + + setTxFlow()} + disabled={!canCancel} + title="Reject transaction" + description="Propose an on-chain cancellation transaction with the same nonce" + chip={canDelete ? 'Recommended' : undefined} + /> + + + + + {canDelete && ( + setTxFlow(undefined)} + /> + )} + + + + ) +} + +export default ReplaceTxMenu diff --git a/src/components/tx-flow/flows/ReplaceTx/styles.module.css b/apps/web/src/components/tx-flow/flows/ReplaceTx/styles.module.css similarity index 100% rename from src/components/tx-flow/flows/ReplaceTx/styles.module.css rename to apps/web/src/components/tx-flow/flows/ReplaceTx/styles.module.css diff --git a/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx b/apps/web/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx similarity index 87% rename from src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx rename to apps/web/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx index 815195e7b..72bdb5f47 100644 --- a/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx +++ b/apps/web/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx @@ -1,5 +1,4 @@ import useWallet from '@/hooks/wallets/useWallet' -import { assertWalletChain } from '@/services/tx/tx-sender/sdk' import { useContext, useEffect, useMemo } from 'react' import type { ReactElement } from 'react' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' @@ -9,13 +8,11 @@ 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 useSafeInfo from '@/hooks/useSafeInfo' 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 { SWAP_TITLE } from '@/features/swap/constants' type ReviewSafeAppsTxProps = { safeAppsTx: SafeAppsTxParams @@ -26,13 +23,17 @@ const ReviewSafeAppsTx = ({ safeAppsTx: { txs, requestId, params, appId, app }, onSubmit, }: ReviewSafeAppsTxProps): ReactElement => { - const { safe } = useSafeInfo() const onboard = useOnboard() const wallet = useWallet() - const { safeTx, setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) + const { safeTx, setSafeTx, safeTxError, setSafeTxError, setTxOrigin } = useContext(SafeTxContext) useHighlightHiddenTab() + useEffect(() => { + setTxOrigin(app?.url) + return () => setTxOrigin(undefined) + }, [setTxOrigin, app?.url]) + useEffect(() => { const createSafeTx = async (): Promise => { const isMultiSend = txs.length > 1 @@ -56,7 +57,6 @@ const ReviewSafeAppsTx = ({ let safeTxHash = '' try { - await assertWalletChain(onboard, safe.chainId) safeTxHash = await dispatchSafeAppsTx(safeTx, requestId, wallet.provider, txId) } catch (error) { setSafeTxError(asError(error)) @@ -69,7 +69,7 @@ const ReviewSafeAppsTx = ({ const error = !isTxValid(txs) return ( - + {error ? ( This Safe App initiated a transaction which cannot be processed. Please get in touch with the developer of diff --git a/src/components/tx-flow/flows/SafeAppsTx/index.tsx b/apps/web/src/components/tx-flow/flows/SafeAppsTx/index.tsx similarity index 100% rename from src/components/tx-flow/flows/SafeAppsTx/index.tsx rename to apps/web/src/components/tx-flow/flows/SafeAppsTx/index.tsx diff --git a/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx b/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx similarity index 93% rename from src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx rename to apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx index c43fd4929..9d57e86fa 100644 --- a/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx +++ b/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx @@ -1,3 +1,5 @@ +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' @@ -11,9 +13,9 @@ import * as useSafeInfoHook from '@/hooks/useSafeInfo' import * as useChainsHook from '@/hooks/useChains' import * as sender from '@/services/safe-messages/safeMsgSender' import * as onboard from '@/hooks/wallets/useOnboard' -import * as sdk from '@/services/tx/tx-sender/sdk' import * as useSafeMessage from '@/hooks/messages/useSafeMessage' -import { render, act, fireEvent, waitFor } from '@/tests/test-utils' +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' @@ -103,7 +105,7 @@ describe('SignMessage', () => { jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false) - jest.spyOn(sdk, 'assertWalletChain').mockImplementation(jest.fn()) + jest.spyOn(sdk, 'useSafeSDK').mockReturnValue({} as unknown as Safe) }) describe('EIP-191 messages', () => { @@ -219,7 +221,7 @@ describe('SignMessage', () => { name="Test App" message="Hello world!" requestId="123" - safeAppId={25} + origin="http://localhost:3000" />, ) @@ -243,7 +245,7 @@ describe('SignMessage', () => { const button = getByText('Sign') - await act(() => { + act(() => { fireEvent.click(button) }) @@ -251,15 +253,17 @@ describe('SignMessage', () => { expect.objectContaining({ safe: extendedSafeInfo, message: 'Hello world!', - safeAppId: 25, + origin: 'http://localhost:3000', //onboard: expect.anything(), }), ) // Immediately refetches message and displays confirmation - expect(baseElement).toHaveTextContent('0x0000...0002') - expect(baseElement).toHaveTextContent('1 of 2') - expect(baseElement).toHaveTextContent('Confirmation #2') + await waitFor(() => { + expect(baseElement).toHaveTextContent('0x0000...0002') + expect(baseElement).toHaveTextContent('1 of 2') + expect(baseElement).toHaveTextContent('Confirmation #2') + }) }) it('confirms the message if already proposed', async () => { @@ -326,7 +330,7 @@ describe('SignMessage', () => { ;(getSafeMessage as jest.Mock).mockResolvedValue(newMsg) - await act(() => { + act(() => { fireEvent.click(button) }) @@ -358,7 +362,7 @@ describe('SignMessage', () => { name="Test App" message="Hello world!" requestId="123" - safeAppId={25} + origin="http://localhost:3000" />, ) @@ -367,26 +371,25 @@ describe('SignMessage', () => { expect(getByText('Sign')).toBeDisabled() }) - it('displays an error if connected to the wrong chain', () => { + it('displays a network switch warning if connected to the wrong chain', () => { jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard) jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true) jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => true) jest.spyOn(useChainsHook, 'useCurrentChain').mockReturnValue(chainBuilder().build()) jest.spyOn(useSafeMessage, 'default').mockImplementation(() => [undefined, jest.fn(), undefined]) - const { getByText } = render( + const { getByText, queryByText } = render( , ) - expect(getByText('Wallet network switch')).toBeInTheDocument() - - expect(getByText('Sign')).not.toBeDisabled() + expect(getByText('Change your wallet network')).toBeInTheDocument() + expect(queryByText('Sign')).toBeDisabled() }) it('displays an error if not an owner', () => { @@ -395,7 +398,7 @@ describe('SignMessage', () => { () => ({ address: zeroPadValue('0x07', 20), - } as ConnectedWallet), + }) as ConnectedWallet, ) jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => false) jest.spyOn(useSafeMessage, 'default').mockImplementation(() => [undefined, jest.fn(), undefined]) @@ -406,7 +409,7 @@ describe('SignMessage', () => { name="Test App" message="Hello world!" requestId="123" - safeAppId={25} + origin="http://localhost:3000" />, ) @@ -424,7 +427,7 @@ describe('SignMessage', () => { () => ({ address: zeroPadValue('0x02', 20), - } as ConnectedWallet), + }) as ConnectedWallet, ) const messageText = 'Hello world!' const messageHash = generateSafeMessageHash( @@ -470,7 +473,7 @@ describe('SignMessage', () => { () => ({ address: zeroPadValue('0x03', 20), - } as ConnectedWallet), + }) as ConnectedWallet, ) jest.spyOn(useSafeMessage, 'default').mockReturnValue([undefined, jest.fn(), undefined]) @@ -488,14 +491,14 @@ describe('SignMessage', () => { name="Test App" message="Hello world!" requestId="123" - safeAppId={25} + origin="http://localhost:3000" />, ) const button = getByText('Sign') expect(button).not.toBeDisabled() - await act(() => { + act(() => { fireEvent.click(button) }) @@ -512,7 +515,7 @@ describe('SignMessage', () => { () => ({ address: zeroPadValue('0x03', 20), - } as ConnectedWallet), + }) as ConnectedWallet, ) const messageText = 'Hello world!' @@ -559,7 +562,7 @@ describe('SignMessage', () => { expect(button).toBeEnabled() - await act(() => { + act(() => { fireEvent.click(button) }) @@ -576,7 +579,7 @@ describe('SignMessage', () => { () => ({ address: zeroPadValue('0x03', 20), - } as ConnectedWallet), + }) as ConnectedWallet, ) const messageText = 'Hello world!' diff --git a/src/components/tx-flow/flows/SignMessage/SignMessage.tsx b/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.tsx similarity index 81% rename from src/components/tx-flow/flows/SignMessage/SignMessage.tsx rename to apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.tsx index f67a8b9ed..13879c8e1 100644 --- a/src/components/tx-flow/flows/SignMessage/SignMessage.tsx +++ b/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.tsx @@ -29,7 +29,6 @@ import useSafeMessage from '@/hooks/messages/useSafeMessage' import useOnboard, { switchWallet } from '@/hooks/wallets/useOnboard' import { TxModalContext } from '@/components/tx-flow' import CopyButton from '@/components/common/CopyButton' -import { WrongChainWarning } from '@/components/tx/WrongChainWarning' import MsgSigners from '@/components/safe-messages/MsgSigners' import useDecodedSafeMessage from '@/hooks/messages/useDecodedSafeMessage' import useSyncSafeMessageSigner from '@/hooks/messages/useSyncSafeMessageSigner' @@ -43,7 +42,6 @@ import { trackEvent } from '@/services/analytics' import { TX_EVENTS, TX_TYPES } from '@/services/analytics/events/transactions' import { SafeTxContext } from '../../SafeTxProvider' import RiskConfirmationError from '@/components/tx/SignOrExecuteForm/RiskConfirmationError' -import { Redefine } from '@/components/tx/security/redefine' import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' import { isBlindSigningPayload, isEIP712TypedData } from '@/utils/safe-messages' import ApprovalEditor from '@/components/tx/ApprovalEditor' @@ -56,6 +54,11 @@ import { AppRoutes } from '@/config/routes' import { useRouter } from 'next/router' import MsgShareLink from '@/components/safe-messages/MsgShareLink' import LinkIcon from '@/public/images/messages/link.svg' +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' const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => { return { @@ -78,7 +81,13 @@ const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => { const MessageHashField = ({ label, hashValue }: { label: string; hashValue: string }) => ( <> - + {label}: @@ -89,14 +98,31 @@ const MessageHashField = ({ label, hashValue }: { label: string; hashValue: stri const DialogHeader = ({ threshold }: { threshold: number }) => ( <> - + - + Confirm message {threshold > 1 && ( - + To sign this message, collect signatures from {threshold} signers of your Safe Account. )} @@ -111,12 +137,12 @@ const MessageDialogError = ({ isOwner, submitError }: { isOwner: boolean; submit !wallet || !onboard ? 'No wallet is connected.' : !isOwner - ? "You are currently not a signer of this Safe Account and won't be able to confirm this message." - : submitError && isWalletRejection(submitError) - ? 'User rejected signing.' - : submitError - ? 'Error confirming the message. Please try again.' - : null + ? "You are currently not a signer of this Safe Account and won't be able to confirm this message." + : submitError && isWalletRejection(submitError) + ? 'User rejected signing.' + : submitError + ? 'Error confirming the message. Please try again.' + : null if (errorMessage) { return {errorMessage} @@ -137,7 +163,13 @@ const AlreadySignedByOwnerMessage = ({ hasSigned }: { hasSigned: boolean }) => { } return ( - + Your connected wallet has already signed this message. @@ -191,10 +223,15 @@ const BlindSigningWarning = ({ const SuccessCard = ({ safeMessage, onContinue }: { safeMessage: SafeMessage; onContinue: () => void }) => { return ( - + Message successfully signed - + + {(isOk) => ( + + )} + diff --git a/apps/web/src/components/tx-flow/flows/SignMessage/index.tsx b/apps/web/src/components/tx-flow/flows/SignMessage/index.tsx new file mode 100644 index 000000000..98294058a --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/SignMessage/index.tsx @@ -0,0 +1,66 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import SignMessage, { type SignMessageProps } from '@/components/tx-flow/flows/SignMessage/SignMessage' +import { getSwapTitle } from '@/features/swap' +import { selectSwapParams } from '@/features/swap/store/swapParamsSlice' +import { useAppSelector } from '@/store' +import { Box, Typography } from '@mui/material' +import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' +import { ErrorBoundary } from '@sentry/react' +import { type BaseTransaction } from '@safe-global/safe-apps-sdk' +import { SWAP_TITLE } from '@/features/swap/constants' +import { STAKE_TITLE } from '@/features/stake/constants' +import { getStakeTitle } from '@/features/stake/helpers/utils' + +const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg' +const APP_NAME_FALLBACK = 'Sign message' + +export const AppTitle = ({ + name, + logoUri, + txs, +}: { + name?: string | null + logoUri?: string | null + txs?: BaseTransaction[] +}) => { + const swapParams = useAppSelector(selectSwapParams) + + const appName = name || APP_NAME_FALLBACK + const appLogo = logoUri || APP_LOGO_FALLBACK_IMAGE + + let title = appName + if (name === SWAP_TITLE) { + title = getSwapTitle(swapParams.tradeType, txs) || title + } + + if (name === STAKE_TITLE) { + title = getStakeTitle(txs) || title + } + + return ( + + + + {title} + + + ) +} + +const SignMessageFlow = ({ ...props }: SignMessageProps) => { + return ( + } + step={0} + hideNonce + isMessage + > + Error signing message}> + + + + ) +} + +export default SignMessageFlow 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 new file mode 100644 index 000000000..59fbd945b --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx @@ -0,0 +1,115 @@ +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 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' +import { JsonRpcProvider } from 'ethers' +import { act } from '@testing-library/react' +import { faker } from '@faker-js/faker' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import type { SafeTxContextParams } from '../../SafeTxProvider' +import { SafeTxContext } from '../../SafeTxProvider' +import { createSafeTx } from '@/tests/builders/safeTx' +import * as useTxPreviewHooks from '@/components/tx/confirmation-views/useTxPreview' + +jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([]) +describe('ReviewSignMessageOnChain', () => { + test('can handle messages with EIP712Domain type in the JSON-RPC payload', async () => { + jest.spyOn(useTxPreviewHooks, 'default').mockReturnValue([ + { + txInfo: {}, + txData: {}, + } as TransactionPreview, + undefined, + false, + ]) + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => new JsonRpcProvider()) + const safeAddress = faker.finance.ethereumAddress() + jest.spyOn(useSafeInfo, 'default').mockReturnValue({ + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .build(), + safeLoaded: true, + safeLoading: false, + }) + + await act(async () => { + render( + + + , + ) + }) + + expect(screen.getByText('Interact with SignMessageLib')).toBeInTheDocument() + }) +}) diff --git a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx similarity index 91% rename from src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx rename to apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx index 74c54a9fd..1cc055aa1 100644 --- a/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx +++ b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx @@ -1,5 +1,4 @@ import useWallet from '@/hooks/wallets/useWallet' -import { assertWalletChain } from '@/services/tx/tx-sender/sdk' import type { ReactElement } from 'react' import { useContext, useEffect, useState } from 'react' import { useMemo } from 'react' @@ -14,8 +13,6 @@ 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 { generateDataRowValue } from '@/components/transactions/TxDetails/Summary/TxDataRow' -import useChainId from '@/hooks/useChainId' import { getReadOnlySignMessageLibContract } from '@/services/contracts/safeContracts' import { DecodedMsg } from '@/components/safe-messages/DecodedMsg' import CopyButton from '@/components/common/CopyButton' @@ -31,6 +28,7 @@ import { isEIP712TypedData } from '@/utils/safe-messages' import ApprovalEditor from '@/components/tx/ApprovalEditor' import { ErrorBoundary } from '@sentry/react' import useAsync from '@/hooks/useAsync' +import { HexEncodedData } from '@/components/transactions/HexEncodedData' export type SignMessageOnChainProps = { app?: SafeAppData @@ -40,7 +38,6 @@ export type SignMessageOnChainProps = { } const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnChainProps): ReactElement => { - const chainId = useChainId() const { safe } = useSafeInfo() const onboard = useOnboard() const wallet = useWallet() @@ -51,8 +48,8 @@ const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnC const isTypedMessage = method === Methods.signTypedMessage && isEIP712TypedData(message) const [readOnlySignMessageLibContract] = useAsync( - async () => getReadOnlySignMessageLibContract(chainId, safe.version), - [chainId, safe.version], + async () => getReadOnlySignMessageLibContract(safe.version), + [safe.version], ) const [signMessageAddress, setSignMessageAddress] = useState('') @@ -114,7 +111,6 @@ const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnC if (!safeTx || !onboard || !wallet) return try { - await assertWalletChain(onboard, safe.chainId) await dispatchSafeAppsTx(safeTx, requestId, wallet.provider) } catch (error) { setSafeTxError(asError(error)) @@ -137,10 +133,7 @@ const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnC {safeTx && ( - - Data (hex encoded) - - {generateDataRowValue(safeTx.data.data, 'rawData')} + )} diff --git a/src/components/tx-flow/flows/SignMessageOnChain/index.tsx b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/index.tsx similarity index 100% rename from src/components/tx-flow/flows/SignMessageOnChain/index.tsx rename to apps/web/src/components/tx-flow/flows/SignMessageOnChain/index.tsx diff --git a/apps/web/src/components/tx-flow/flows/SuccessScreen/StatusMessage.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/StatusMessage.tsx new file mode 100644 index 000000000..456e43d33 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/SuccessScreen/StatusMessage.tsx @@ -0,0 +1,55 @@ +import { isTimeoutError } from '@/utils/ethers-utils' +import classNames from 'classnames' +import { Box, Typography } from '@mui/material' +import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' +import { PendingStatus } from '@/store/pendingTxsSlice' +import css from './styles.module.css' + +const getStep = (status: PendingStatus, error?: Error) => { + switch (status) { + case PendingStatus.PROCESSING: + case PendingStatus.RELAYING: + return { + description: 'Transaction is now processing', + instruction: 'The transaction was confirmed and is now being processed.', + classNames: '', + } + case PendingStatus.INDEXING: + return { + description: 'Transaction was processed', + instruction: 'It is now being indexed.', + classNames: classNames(css.instructions, error ? css.errorBg : css.infoBg), + } + default: + return { + description: error ? 'Transaction failed' : 'Transaction was successful', + instruction: error ? (isTimeoutError(error) ? 'Transaction timed out' : error.message) : '', + classNames: classNames(css.instructions, error ? css.errorBg : css.infoBg), + } + } +} + +const StatusMessage = ({ status, error }: { status: PendingStatus; error?: Error }) => { + const stepInfo = getStep(status, error) + + const isSuccess = status === undefined + const spinnerStatus = error ? SpinnerStatus.ERROR : isSuccess ? SpinnerStatus.SUCCESS : SpinnerStatus.PROCESSING + + return ( + <> + + + + {stepInfo.description} + + + {stepInfo.instruction && ( + + {stepInfo.instruction} + + )} + + ) +} + +export default StatusMessage diff --git a/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx similarity index 100% rename from src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx rename to apps/web/src/components/tx-flow/flows/SuccessScreen/StatusStepper.tsx diff --git a/apps/web/src/components/tx-flow/flows/SuccessScreen/index.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/index.tsx new file mode 100644 index 000000000..b70605ac2 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/SuccessScreen/index.tsx @@ -0,0 +1,134 @@ +import StatusStepper from './StatusStepper' +import { Button, Container, Divider, Paper } from '@mui/material' +import classnames from 'classnames' +import Link from 'next/link' +import css from './styles.module.css' +import { useAppSelector } from '@/store' +import { PendingStatus, selectPendingTxById } from '@/store/pendingTxsSlice' +import { useCallback, useContext, useEffect, useState } from 'react' +import { useCurrentChain } from '@/hooks/useChains' +import { TxEvent, txSubscribe } from '@/services/tx/txEvents' +import useSafeInfo from '@/hooks/useSafeInfo' +import { TxModalContext } from '../..' +import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' +import { ProcessingStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus' +import { IndexingStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus' +import { DefaultStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus' +import { isSwapTransferOrderTxInfo } from '@/utils/transaction-guards' +import { getTxLink } from '@/utils/tx-link' +import useTxDetails from '@/hooks/useTxDetails' + +interface Props { + /** The ID assigned to the transaction in the client-gateway */ + txId?: string + /** For module transaction, pass the transaction hash while the `txId` is not yet available */ + txHash?: string +} + +const SuccessScreen = ({ txId, txHash }: Props) => { + const [localTxHash, setLocalTxHash] = useState(txHash) + const [error, setError] = useState() + const { setTxFlow } = useContext(TxModalContext) + const chain = useCurrentChain() + const pendingTx = useAppSelector((state) => (txId ? selectPendingTxById(state, txId) : undefined)) + const { safeAddress } = useSafeInfo() + const status = !txId && txHash ? PendingStatus.INDEXING : pendingTx?.status + const pendingTxHash = pendingTx && 'txHash' in pendingTx ? pendingTx.txHash : undefined + const txLink = chain && txId && getTxLink(txId, chain, safeAddress) + const [txDetails] = useTxDetails(txId) + const isSwapOrder = txDetails && isSwapTransferOrderTxInfo(txDetails.txInfo) + + useEffect(() => { + if (!pendingTxHash) return + + setLocalTxHash(pendingTxHash) + }, [pendingTxHash]) + + useEffect(() => { + const unsubFns: Array<() => void> = ([TxEvent.FAILED, TxEvent.REVERTED] as const).map((event) => + txSubscribe(event, (detail) => { + if (detail.txId === txId && pendingTx) setError(detail.error) + }), + ) + + return () => unsubFns.forEach((unsubscribe) => unsubscribe()) + }, [txId, pendingTx]) + + const onClose = useCallback(() => { + setTxFlow(undefined) + }, [setTxFlow]) + + const isSuccess = status === undefined + const spinnerStatus = error ? SpinnerStatus.ERROR : isSuccess ? SpinnerStatus.SUCCESS : SpinnerStatus.PROCESSING + + let StatusComponent + switch (status) { + case PendingStatus.PROCESSING: + case PendingStatus.RELAYING: + // status can only have these values if txId & pendingTx are defined + StatusComponent = + break + case PendingStatus.INDEXING: + StatusComponent = + break + default: + StatusComponent = + } + + return ( + +
+ + {StatusComponent} +
+ + {!error && ( + <> + +
+ +
+ + )} + + + +
+ {isSwapOrder && ( + + )} + + {txLink && ( + + + + )} + + {!isSwapOrder && ( + + )} +
+
+ ) +} + +export default SuccessScreen diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx similarity index 86% rename from src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx rename to apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx index 45fae9fda..3166e284a 100644 --- a/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx +++ b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx @@ -10,8 +10,8 @@ type Props = { error: undefined | Error } export const DefaultStatus = ({ error }: Props) => ( - - + + {error ? TRANSACTION_FAILED : TRANSACTION_SUCCESSFUL} {error && ( diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx similarity index 76% rename from src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx rename to apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx index 088f7cf31..7b98f3da1 100644 --- a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx +++ b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx @@ -3,8 +3,8 @@ import classNames from 'classnames' import css from '@/components/tx-flow/flows/SuccessScreen/styles.module.css' export const IndexingStatus = () => ( - - + + Transaction was processed diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx similarity index 85% rename from src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx rename to apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx index 5c94c74f8..58a97a052 100644 --- a/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx +++ b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx @@ -8,8 +8,8 @@ type Props = { pendingTx: PendingTx } export const ProcessingStatus = ({ txId, pendingTx }: Props) => ( - - + + Transaction is now processing diff --git a/src/components/tx-flow/flows/SuccessScreen/styles.module.css b/apps/web/src/components/tx-flow/flows/SuccessScreen/styles.module.css similarity index 100% rename from src/components/tx-flow/flows/SuccessScreen/styles.module.css rename to apps/web/src/components/tx-flow/flows/SuccessScreen/styles.module.css diff --git a/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx similarity index 79% rename from src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx rename to apps/web/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx index 4b5c18b3f..271cfd03d 100644 --- a/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx @@ -1,7 +1,6 @@ import { useTokenAmount, useVisibleTokens } from '@/components/tx-flow/flows/TokenTransfer/utils' import { type ReactElement, useContext, useEffect } from 'react' import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' -import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' import { FormProvider, useForm } from 'react-hook-form' import { Button, CardActions, Divider, FormControl, Grid, Typography } from '@mui/material' import TokenIcon from '@/components/common/TokenIcon' @@ -13,13 +12,26 @@ import { formatVisualAmount } from '@/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' export const AutocompleteItem = (item: { tokenInfo: TokenInfo; balance: string }): ReactElement => ( - + - + {item.tokenInfo.name} @@ -40,7 +52,8 @@ export const CreateTokenTransfer = ({ txNonce?: number }): ReactElement => { const disableSpendingLimit = txNonce !== undefined - const isOnlySpendingLimitBeneficiary = useIsOnlySpendingLimitBeneficiary() + const canCreateStandardTx = useHasPermission(Permission.CreateTransaction) + const canCreateSpendingLimitTx = useHasPermission(Permission.CreateSpendingLimitTransaction) const balancesItems = useVisibleTokens() const { setNonce, setNonceNeeded } = useContext(SafeTxContext) @@ -55,12 +68,11 @@ export const CreateTokenTransfer = ({ ...params, [TokenTransferFields.type]: disableSpendingLimit ? TokenTransferType.multiSig - : isOnlySpendingLimitBeneficiary - ? TokenTransferType.spendingLimit - : params.type, - [TokenTransferFields.tokenAddress]: isOnlySpendingLimitBeneficiary - ? balancesItems[0]?.tokenInfo.address - : params.tokenAddress, + : canCreateSpendingLimitTx && !canCreateStandardTx + ? TokenTransferType.spendingLimit + : params.type, + [TokenTransferFields.tokenAddress]: + canCreateSpendingLimitTx && !canCreateStandardTx ? balancesItems[0]?.tokenInfo.address : params.tokenAddress, }, mode: 'onChange', delayError: 500, @@ -79,6 +91,10 @@ export const CreateTokenTransfer = ({ const selectedToken = balancesItems.find((item) => item.tokenInfo.address === tokenAddress) const { totalAmount, spendingLimitAmount } = useTokenAmount(selectedToken) + const canCreateSpendingLimitTxWithToken = useHasPermission(Permission.CreateSpendingLimitTransaction, { + token: selectedToken?.tokenInfo, + }) + const isSpendingLimitType = type === TokenTransferType.spendingLimit const maxAmount = isSpendingLimitType && totalAmount > spendingLimitAmount ? spendingLimitAmount : totalAmount @@ -99,7 +115,7 @@ export const CreateTokenTransfer = ({ - {!disableSpendingLimit && spendingLimitAmount > 0n && ( + {!disableSpendingLimit && canCreateSpendingLimitTxWithToken && ( diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx similarity index 82% rename from src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx rename to apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx index 2f15e2a6f..86a9d6881 100644 --- a/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx @@ -1,5 +1,4 @@ import useWallet from '@/hooks/wallets/useWallet' -import { assertWalletChain } from '@/services/tx/tx-sender/sdk' import type { ReactElement, SyntheticEvent } from 'react' import { useContext, useMemo, useState } from 'react' import { type BigNumberish, type BytesLike, parseUnits } from 'ethers' @@ -21,14 +20,16 @@ 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 { WrongChainWarning } from '@/components/tx/WrongChainWarning' import { asError } from '@/services/exceptions/utils' import TxCard from '@/components/tx-flow/common/TxCard' import { TxModalContext } from '@/components/tx-flow' -import { type SubmitCallback } from '@/components/tx/SignOrExecuteForm' +import { type SubmitCallback } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' import { TX_EVENTS, TX_TYPES } from '@/services/analytics/events/transactions' import { isWalletRejection } from '@/utils/wallets' import { checksumAddress } from '@/utils/addresses' +import { safeParseUnits } from '@/utils/formatters' +import CheckWallet from '@/components/common/CheckWallet' +import NetworkWarning from '@/components/new-safe/create/NetworkWarning' export type SpendingLimitTxParams = { safeAddress: string @@ -60,6 +61,11 @@ const ReviewSpendingLimitTx = ({ const token = balances.items.find((item) => item.tokenInfo.address === params.tokenAddress) const spendingLimit = useSpendingLimit(token?.tokenInfo) + const amountInWei = useMemo( + () => safeParseUnits(params.amount, token?.tokenInfo.decimals)?.toString() || '0', + [params.amount, token?.tokenInfo.decimals], + ) + const txParams: SpendingLimitTxParams = useMemo( () => ({ safeAddress, @@ -98,8 +104,14 @@ const ReviewSpendingLimitTx = ({ const txOptions = getTxOptions(advancedParams, currentChain) try { - await assertWalletChain(onboard, safe.chainId) - await dispatchSpendingLimitTxExecution(txParams, txOptions, wallet.provider, safe.chainId, safeAddress) + await dispatchSpendingLimitTxExecution( + txParams, + txOptions, + wallet.provider, + safe.chainId, + safeAddress, + safe.modules, + ) onSubmit('', true) setTxFlow(undefined) } catch (_err) { @@ -111,10 +123,11 @@ const ReviewSpendingLimitTx = ({ setSubmitError(err) } setIsSubmittable(true) + return } - trackEvent({ ...TX_EVENTS.CREATE, label: TX_TYPES.transfer_token }) - trackEvent({ ...TX_EVENTS.EXECUTE, label: TX_TYPES.transfer_token }) + trackEvent({ ...TX_EVENTS.CREATE_VIA_SPENDING_LIMTI, label: TX_TYPES.transfer_token }) + trackEvent({ ...TX_EVENTS.EXECUTE_VIA_SPENDING_LIMIT, label: TX_TYPES.transfer_token }) } const submitDisabled = !isSubmittable || gasLimitLoading @@ -128,13 +141,13 @@ const ReviewSpendingLimitTx = ({ Blockchain Explorer. - {token && } + {token && } - + {submitError && ( Error submitting the transaction. Please try again. @@ -147,9 +160,13 @@ const ReviewSpendingLimitTx = ({ - + + {(isOk) => ( + + )} + diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx new file mode 100644 index 000000000..0c161bff1 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx @@ -0,0 +1,57 @@ +import { 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 { SafeTxContext } from '../../SafeTxProvider' +import { safeParseUnits } from '@/utils/formatters' +import type { SubmitCallback } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' + +const ReviewTokenTransfer = ({ + params, + onSubmit, + txNonce, +}: { + params: TokenTransferParams + onSubmit: SubmitCallback + txNonce?: number +}) => { + const { setSafeTx, setSafeTxError, setNonce } = 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], + ) + + useEffect(() => { + if (txNonce !== undefined) { + setNonce(txNonce) + } + + if (!token) return + + const txParams = createTokenTransferParams( + params.recipient, + params.amount, + token.tokenInfo.decimals, + token.tokenInfo.address, + ) + + createTx(txParams, txNonce).then(setSafeTx).catch(setSafeTxError) + }, [params, txNonce, token, setNonce, setSafeTx, setSafeTxError]) + + return ( + + {token && } + + + + ) +} + +export default ReviewTokenTransfer diff --git a/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx similarity index 100% rename from src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx rename to apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx new file mode 100644 index 000000000..6cc22ea22 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx @@ -0,0 +1,49 @@ +import { type ReactNode } from 'react' +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' + +const SendAmountBlock = ({ + amountInWei, + tokenInfo, + children, + title = 'Send:', +}: { + /** Amount in WEI */ + amountInWei: number | string + tokenInfo: Omit & { logoUri?: string } + children?: ReactNode + title?: string +}) => { + return ( + + + + + + {tokenInfo.symbol} + + + {children} + + + {formatVisualAmount(amountInWei, tokenInfo.decimals, tokenInfo.decimals)} + + + + ) +} + +export default SendAmountBlock 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 new file mode 100644 index 000000000..70bdbd1b1 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/SpendingLimitRow/index.tsx @@ -0,0 +1,160 @@ +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 type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { TokenTransferFields, 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' + +const SpendingLimitRow = ({ + availableAmount, + selectedToken, +}: { + availableAmount: bigint + selectedToken: TokenInfo | undefined +}) => { + const { control, trigger, resetField } = useFormContext() + const canCreateStandardTx = useHasPermission(Permission.CreateTransaction) + const canCreateSpendingLimitTx = useHasPermission(Permission.CreateSpendingLimitTransaction, { token: selectedToken }) + const { setNonceNeeded } = useContext(SafeTxContext) + + const formattedAmount = safeFormatUnits(availableAmount, selectedToken?.decimals) + + useEffect(() => { + return () => { + // reset the field value to default when the component is unmounted + resetField(TokenTransferFields.type) + } + }, [resetField]) + + return ( + + + Send as + + ( + { + onChange(e) + + setNonceNeeded(e.target.value === TokenTransferType.multiSig) + + // Validate only after the field is changed + setTimeout(() => { + trigger(TokenAmountFields.amount) + }, 10) + }} + {...field} + defaultValue={TokenTransferType.multiSig} + className={css.group} + > + {canCreateStandardTx && ( + + Standard transaction + + A standard transaction requires the signatures of other signers before the specified funds can + be transferred.  + + Learn more about spending limits + + . + + } + arrow + placement="top" + > + + + + + + } + control={} + componentsProps={{ typography: { variant: 'body2' } }} + className={css.label} + /> + )} + {canCreateSpendingLimitTx && ( + + Spending limit {`(${formattedAmount} ${selectedToken?.symbol})`} + + A spending limit transaction allows you to transfer the specified funds without the need to + collect the signatures of other signers.  + + Learn more about spending limits + + . + + } + arrow + placement="top" + > + + + + + + } + control={} + componentsProps={{ typography: { variant: 'body2' } }} + className={classNames(css.label, { [css.spendingLimit]: canCreateStandardTx })} + /> + )} + + )} + /> + + ) +} + +export default SpendingLimitRow diff --git a/src/components/tx-flow/flows/TokenTransfer/SpendingLimitRow/styles.module.css b/apps/web/src/components/tx-flow/flows/TokenTransfer/SpendingLimitRow/styles.module.css similarity index 100% rename from src/components/tx-flow/flows/TokenTransfer/SpendingLimitRow/styles.module.css rename to apps/web/src/components/tx-flow/flows/TokenTransfer/SpendingLimitRow/styles.module.css 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 new file mode 100644 index 000000000..210728d31 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/__tests__/CreateTokenTransfer.test.tsx @@ -0,0 +1,88 @@ +import { TokenTransferType } from '@/components/tx-flow/flows/TokenTransfer' +import { CreateTokenTransfer } 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 { 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' + +describe('CreateTokenTransfer', () => { + const mockParams = { + recipient: '', + tokenAddress: ZERO_ADDRESS, + amount: '', + type: TokenTransferType.multiSig, + } + + const useHasPermissionSpy = jest.spyOn(useHasPermission, 'useHasPermission') + + beforeEach(() => { + jest.clearAllMocks() + useHasPermissionSpy.mockReturnValue(true) + }) + + it('should display a token amount input', () => { + const { getByText } = render() + + expect(getByText('Amount')).toBeInTheDocument() + }) + + it('should display a recipient input', () => { + const { getAllByText } = render() + + expect(getAllByText('Recipient address')[0]).toBeInTheDocument() + }) + + it('should display a type selection if a spending limit token is selected', () => { + jest + .spyOn(tokenUtils, 'useTokenAmount') + .mockReturnValue({ totalAmount: BigInt(1000), spendingLimitAmount: BigInt(500) }) + + const tokenAddress = ZERO_ADDRESS + + const { getByText } = render(, { + 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', + }, + ], + }, + }, + }, + }) + + expect(getByText('Send as')).toBeInTheDocument() + + expect(useHasPermissionSpy).toHaveBeenCalledWith(Permission.CreateSpendingLimitTransaction) + }) + + it('should not display a type selection if user does not have `CreateSpendingLimitTransaction` permission', () => { + useHasPermissionSpy.mockReturnValueOnce(false) + const { queryByText } = render() + + 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() + + expect(queryByText('Send as')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/tx-flow/flows/TokenTransfer/__tests__/utils.test.ts b/apps/web/src/components/tx-flow/flows/TokenTransfer/__tests__/utils.test.ts similarity index 100% rename from src/components/tx-flow/flows/TokenTransfer/__tests__/utils.test.ts rename to apps/web/src/components/tx-flow/flows/TokenTransfer/__tests__/utils.test.ts diff --git a/src/components/tx-flow/flows/TokenTransfer/index.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/index.tsx similarity index 100% rename from src/components/tx-flow/flows/TokenTransfer/index.tsx rename to apps/web/src/components/tx-flow/flows/TokenTransfer/index.tsx diff --git a/src/components/tx-flow/flows/TokenTransfer/utils.ts b/apps/web/src/components/tx-flow/flows/TokenTransfer/utils.ts similarity index 100% rename from src/components/tx-flow/flows/TokenTransfer/utils.ts rename to apps/web/src/components/tx-flow/flows/TokenTransfer/utils.ts diff --git a/apps/web/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx b/apps/web/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx new file mode 100644 index 000000000..14e865223 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx @@ -0,0 +1,25 @@ +import { useContext } from 'react' +import { useCurrentChain } from '@/hooks/useChains' +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' + +export const UpdateSafeReview = () => { + const { safe, safeLoaded } = useSafeInfo() + const chain = useCurrentChain() + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + + useAsync(async () => { + if (!chain || !safeLoaded) return + + const txs = await createUpdateSafeTxs(safe, chain) + const safeTxPromise = txs.length > 1 ? createMultiSendCallOnlyTx(txs) : createTx(txs[0]) + + safeTxPromise.then(setSafeTx).catch(setSafeTxError) + }, [safe, safeLoaded, chain, setSafeTx, setSafeTxError]) + + return +} diff --git a/src/components/tx-flow/flows/UpdateSafe/index.tsx b/apps/web/src/components/tx-flow/flows/UpdateSafe/index.tsx similarity index 100% rename from src/components/tx-flow/flows/UpdateSafe/index.tsx rename to apps/web/src/components/tx-flow/flows/UpdateSafe/index.tsx diff --git a/src/components/tx-flow/flows/UpsertRecovery/RecovererSmartContractWarning.tsx b/apps/web/src/components/tx-flow/flows/UpsertRecovery/RecovererSmartContractWarning.tsx similarity index 100% rename from src/components/tx-flow/flows/UpsertRecovery/RecovererSmartContractWarning.tsx rename to apps/web/src/components/tx-flow/flows/UpsertRecovery/RecovererSmartContractWarning.tsx diff --git a/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx b/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx similarity index 84% rename from src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx rename to apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx index 0b23515ff..0871cd583 100644 --- a/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx +++ b/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx @@ -38,15 +38,33 @@ const RecoverySteps = [ export function UpsertRecoveryFlowIntro({ onSubmit }: { onSubmit: () => void }): ReactElement { return ( - + {RecoverySteps.map(({ Icon, title, subtitle }, index) => ( - + - + {title} {subtitle} @@ -55,9 +73,7 @@ export function UpsertRecoveryFlowIntro({ onSubmit }: { onSubmit: () => void }): ))} - -