diff --git a/e2e-tests/popup/import-account-with-file/import-account-with-file.spec.ts b/e2e-tests/popup/import-account-with-file/import-account-with-file.spec.ts index c7847bd4a..520e115c5 100644 --- a/e2e-tests/popup/import-account-with-file/import-account-with-file.spec.ts +++ b/e2e-tests/popup/import-account-with-file/import-account-with-file.spec.ts @@ -58,7 +58,7 @@ popup.describe('Popup UI: import account with file', () => { popupPage.getByText(IMPORTED_PEM_ACCOUNT.truncatedPublicKey) ).toBeVisible(); await popupExpect( - popupPage.getByText('Imported', { exact: true }) + popupPage.getByTestId('import-account-icon') ).toBeVisible(); } ); @@ -112,7 +112,7 @@ popup.describe('Popup UI: import account with file', () => { popupPage.getByText(IMPORTED_CER_ACCOUNT.truncatedPublicKey) ).toBeVisible(); await popupExpect( - popupPage.getByText('Imported', { exact: true }) + popupPage.getByTestId('import-account-icon') ).toBeVisible(); } ); diff --git a/e2e-tests/popup/import-torus-account/import-torus-account.spec.ts b/e2e-tests/popup/import-torus-account/import-torus-account.spec.ts index 195b70f64..72705508f 100644 --- a/e2e-tests/popup/import-torus-account/import-torus-account.spec.ts +++ b/e2e-tests/popup/import-torus-account/import-torus-account.spec.ts @@ -46,7 +46,7 @@ popup.describe('Popup UI: import account with file', () => { popupPage.getByText(IMPORTED_TORUS_ACCOUNT.truncatedPublicKey) ).toBeVisible(); await popupExpect( - popupPage.getByText('Imported', { exact: true }) + popupPage.getByTestId('import-account-icon') ).toBeVisible(); }); }); diff --git a/package-lock.json b/package-lock.json index dd0b8d344..52f51f57e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,20 @@ { "name": "Casper Wallet", - "version": "1.8.1", + "version": "1.10.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "Casper Wallet", - "version": "1.8.1", + "version": "1.10.0", "dependencies": { "@formatjs/intl": "2.6.2", "@hookform/resolvers": "2.9.10", "@lapo/asn1js": "1.2.4", + "@ledgerhq/hw-transport": "^6.30.6", + "@ledgerhq/hw-transport-web-ble": "^6.28.6", + "@ledgerhq/hw-transport-webhid": "^6.28.6", + "@ledgerhq/hw-transport-webusb": "^6.28.6", "@lottiefiles/react-lottie-player": "3.5.3", "@make-software/ces-js-parser": "1.3.2", "@noble/ciphers": "^0.3.0", @@ -18,6 +22,7 @@ "@scure/bip39": "1.2.1", "@types/argon2-browser": "1.18.1", "@types/webextension-polyfill": "0.9.2", + "@zondax/ledger-casper": "^2.6.1", "base64-loader": "1.0.0", "big.js": "^6.2.1", "casper-cep18-js-client": "1.0.2", @@ -4563,6 +4568,86 @@ "resolved": "https://registry.npmjs.org/@lapo/asn1js/-/asn1js-1.2.4.tgz", "integrity": "sha512-mdInpQZaYUWu5QbKIB2+Vd+j6Y7cc6xQYNwYBPC9jri2rwy3tbxom0IhhT4G5WOKWO7Iht10SxYpKq+AfuH6dw==" }, + "node_modules/@ledgerhq/devices": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.3.0.tgz", + "integrity": "sha512-h5Scr+yIae8yjPOViCHLdMjpqn4oC2Whrsq8LinRxe48LEGMdPqSV1yY7+3Ch827wtzNpMv+/ilKnd8rY+rTlg==", + "dependencies": { + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/logs": "^6.12.0", + "rxjs": "^7.8.1", + "semver": "^7.3.5" + } + }, + "node_modules/@ledgerhq/devices/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ledgerhq/errors": { + "version": "6.16.4", + "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-6.16.4.tgz", + "integrity": "sha512-M57yFaLYSN+fZCX0E0zUqOmrV6eipK+s5RhijHoUNlHUqrsvUz7iRQgpd5gRgHB5VkIjav7KdaZjKiWGcHovaQ==" + }, + "node_modules/@ledgerhq/hw-transport": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-6.30.6.tgz", + "integrity": "sha512-fT0Z4IywiuJuZrZE/+W0blkV5UCotDPFTYKLkKCLzYzuE6javva7D/ajRaIeR+hZ4kTmKF4EqnsmDCXwElez+w==", + "dependencies": { + "@ledgerhq/devices": "^8.3.0", + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/logs": "^6.12.0", + "events": "^3.3.0" + } + }, + "node_modules/@ledgerhq/hw-transport-web-ble": { + "version": "6.28.6", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-web-ble/-/hw-transport-web-ble-6.28.6.tgz", + "integrity": "sha512-SsseU5T4ePhdvFdwUOsF207gyMgiHyymvRAV66/hpHCd0+m/81kV8nZneeD3Z1pG0XPG+tPlF90r7nLwtUoiXw==", + "dependencies": { + "@ledgerhq/devices": "^8.3.0", + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/hw-transport": "^6.30.6", + "@ledgerhq/logs": "^6.12.0", + "rxjs": "^7.8.1" + } + }, + "node_modules/@ledgerhq/hw-transport-webhid": { + "version": "6.28.6", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-webhid/-/hw-transport-webhid-6.28.6.tgz", + "integrity": "sha512-npU1mgL97KovpTUgcdORoOZ7eVFgwCA7zt0MpgUGUMRNJWDgCFsJslx7KrVXlCGOg87gLfDojreIre502I5pYg==", + "dependencies": { + "@ledgerhq/devices": "^8.3.0", + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/hw-transport": "^6.30.6", + "@ledgerhq/logs": "^6.12.0" + } + }, + "node_modules/@ledgerhq/hw-transport-webusb": { + "version": "6.28.6", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-webusb/-/hw-transport-webusb-6.28.6.tgz", + "integrity": "sha512-rzICsvhcFcL4wSAvRPe+b9EEWB8cxj6yWy3FZdfs7ufi/0muNpFXWckWv1TC34em55sGXu2cMcwMKXg/O/Lc0Q==", + "dependencies": { + "@ledgerhq/devices": "^8.3.0", + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/hw-transport": "^6.30.6", + "@ledgerhq/logs": "^6.12.0" + } + }, + "node_modules/@ledgerhq/logs": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-6.12.0.tgz", + "integrity": "sha512-ExDoj1QV5eC6TEbMdLUMMk9cfvNKhhv5gXol4SmULRVCx/3iyCPhJ74nsb3S0Vb+/f+XujBEj3vQn5+cwS0fNA==" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -7057,6 +7142,14 @@ "@types/node": "*" } }, + "node_modules/@types/ledgerhq__hw-transport": { + "version": "4.21.8", + "resolved": "https://registry.npmjs.org/@types/ledgerhq__hw-transport/-/ledgerhq__hw-transport-4.21.8.tgz", + "integrity": "sha512-uO2AJYZUVCwgyqgyy2/KW+JsQaO0hcwDdubRaHgF2ehO0ngGAY41PbE8qnPnmUw1uerMXONvL68QFioA7Y6C5g==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.14.202", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", @@ -7816,6 +7909,16 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "node_modules/@zondax/ledger-casper": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@zondax/ledger-casper/-/ledger-casper-2.6.1.tgz", + "integrity": "sha512-Zk+DOVK9G9Gyt7ua9x/G5iVVSlNfp1l+Tek+7+MoqP5aTr4YznBJIhdnPD8yYSxEEZZLs8Q0tV0TyfsXeRokQw==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "@ledgerhq/hw-transport": "^6.28.2", + "@types/ledgerhq__hw-transport": "^4.21.4" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -14323,7 +14426,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "engines": { "node": ">=0.8.x" } @@ -20882,7 +20984,7 @@ "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, "node_modules/lodash.get": { @@ -21115,7 +21217,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -25059,10 +25160,9 @@ "dev": true }, "node_modules/rxjs": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", - "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", - "dev": true, + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dependencies": { "tslib": "^2.1.0" } @@ -29322,8 +29422,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { "version": "1.10.2", @@ -32603,6 +32702,82 @@ "resolved": "https://registry.npmjs.org/@lapo/asn1js/-/asn1js-1.2.4.tgz", "integrity": "sha512-mdInpQZaYUWu5QbKIB2+Vd+j6Y7cc6xQYNwYBPC9jri2rwy3tbxom0IhhT4G5WOKWO7Iht10SxYpKq+AfuH6dw==" }, + "@ledgerhq/devices": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.3.0.tgz", + "integrity": "sha512-h5Scr+yIae8yjPOViCHLdMjpqn4oC2Whrsq8LinRxe48LEGMdPqSV1yY7+3Ch827wtzNpMv+/ilKnd8rY+rTlg==", + "requires": { + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/logs": "^6.12.0", + "rxjs": "^7.8.1", + "semver": "^7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "@ledgerhq/errors": { + "version": "6.16.4", + "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-6.16.4.tgz", + "integrity": "sha512-M57yFaLYSN+fZCX0E0zUqOmrV6eipK+s5RhijHoUNlHUqrsvUz7iRQgpd5gRgHB5VkIjav7KdaZjKiWGcHovaQ==" + }, + "@ledgerhq/hw-transport": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-6.30.6.tgz", + "integrity": "sha512-fT0Z4IywiuJuZrZE/+W0blkV5UCotDPFTYKLkKCLzYzuE6javva7D/ajRaIeR+hZ4kTmKF4EqnsmDCXwElez+w==", + "requires": { + "@ledgerhq/devices": "^8.3.0", + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/logs": "^6.12.0", + "events": "^3.3.0" + } + }, + "@ledgerhq/hw-transport-web-ble": { + "version": "6.28.6", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-web-ble/-/hw-transport-web-ble-6.28.6.tgz", + "integrity": "sha512-SsseU5T4ePhdvFdwUOsF207gyMgiHyymvRAV66/hpHCd0+m/81kV8nZneeD3Z1pG0XPG+tPlF90r7nLwtUoiXw==", + "requires": { + "@ledgerhq/devices": "^8.3.0", + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/hw-transport": "^6.30.6", + "@ledgerhq/logs": "^6.12.0", + "rxjs": "^7.8.1" + } + }, + "@ledgerhq/hw-transport-webhid": { + "version": "6.28.6", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-webhid/-/hw-transport-webhid-6.28.6.tgz", + "integrity": "sha512-npU1mgL97KovpTUgcdORoOZ7eVFgwCA7zt0MpgUGUMRNJWDgCFsJslx7KrVXlCGOg87gLfDojreIre502I5pYg==", + "requires": { + "@ledgerhq/devices": "^8.3.0", + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/hw-transport": "^6.30.6", + "@ledgerhq/logs": "^6.12.0" + } + }, + "@ledgerhq/hw-transport-webusb": { + "version": "6.28.6", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-webusb/-/hw-transport-webusb-6.28.6.tgz", + "integrity": "sha512-rzICsvhcFcL4wSAvRPe+b9EEWB8cxj6yWy3FZdfs7ufi/0muNpFXWckWv1TC34em55sGXu2cMcwMKXg/O/Lc0Q==", + "requires": { + "@ledgerhq/devices": "^8.3.0", + "@ledgerhq/errors": "^6.16.4", + "@ledgerhq/hw-transport": "^6.30.6", + "@ledgerhq/logs": "^6.12.0" + } + }, + "@ledgerhq/logs": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-6.12.0.tgz", + "integrity": "sha512-ExDoj1QV5eC6TEbMdLUMMk9cfvNKhhv5gXol4SmULRVCx/3iyCPhJ74nsb3S0Vb+/f+XujBEj3vQn5+cwS0fNA==" + }, "@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -34630,6 +34805,14 @@ "@types/node": "*" } }, + "@types/ledgerhq__hw-transport": { + "version": "4.21.8", + "resolved": "https://registry.npmjs.org/@types/ledgerhq__hw-transport/-/ledgerhq__hw-transport-4.21.8.tgz", + "integrity": "sha512-uO2AJYZUVCwgyqgyy2/KW+JsQaO0hcwDdubRaHgF2ehO0ngGAY41PbE8qnPnmUw1uerMXONvL68QFioA7Y6C5g==", + "requires": { + "@types/node": "*" + } + }, "@types/lodash": { "version": "4.14.202", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", @@ -35253,6 +35436,16 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "@zondax/ledger-casper": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@zondax/ledger-casper/-/ledger-casper-2.6.1.tgz", + "integrity": "sha512-Zk+DOVK9G9Gyt7ua9x/G5iVVSlNfp1l+Tek+7+MoqP5aTr4YznBJIhdnPD8yYSxEEZZLs8Q0tV0TyfsXeRokQw==", + "requires": { + "@babel/runtime": "^7.21.0", + "@ledgerhq/hw-transport": "^6.28.2", + "@types/ledgerhq__hw-transport": "^4.21.4" + } + }, "abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -40139,8 +40332,7 @@ "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, "eventsource": { "version": "2.0.2", @@ -45080,7 +45272,7 @@ "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, "lodash.get": { @@ -45260,7 +45452,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -48218,10 +48409,9 @@ "dev": true }, "rxjs": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", - "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", - "dev": true, + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "requires": { "tslib": "^2.1.0" } @@ -51456,8 +51646,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yaml": { "version": "1.10.2", diff --git a/package.json b/package.json index 6624f27a6..475187cac 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "Casper Wallet", "description": "Securely manage your CSPR tokens and interact with dapps with the self-custody wallet for the Casper blockchain.", - "version": "1.9.1", + "version": "1.10.0", "author": "MAKE LLC", "scripts": { "devtools:redux": "redux-devtools --hostname=localhost", @@ -55,6 +55,10 @@ "@formatjs/intl": "2.6.2", "@hookform/resolvers": "2.9.10", "@lapo/asn1js": "1.2.4", + "@ledgerhq/hw-transport": "^6.30.6", + "@ledgerhq/hw-transport-web-ble": "^6.28.6", + "@ledgerhq/hw-transport-webhid": "^6.28.6", + "@ledgerhq/hw-transport-webusb": "^6.28.6", "@lottiefiles/react-lottie-player": "3.5.3", "@make-software/ces-js-parser": "1.3.2", "@noble/ciphers": "^0.3.0", @@ -62,6 +66,7 @@ "@scure/bip39": "1.2.1", "@types/argon2-browser": "1.18.1", "@types/webextension-polyfill": "0.9.2", + "@zondax/ledger-casper": "^2.6.1", "base64-loader": "1.0.0", "big.js": "^6.2.1", "casper-cep18-js-client": "1.0.2", diff --git a/src/apps/popup/app-router.tsx b/src/apps/popup/app-router.tsx index 5b6515491..8cd0a67d0 100644 --- a/src/apps/popup/app-router.tsx +++ b/src/apps/popup/app-router.tsx @@ -18,6 +18,7 @@ import { ContactsBookPage } from '@popup/pages/contacts'; import { CreateAccountPage } from '@popup/pages/create-account'; import { DownloadAccountKeysPage } from '@popup/pages/download-account-keys'; import { HomePageContent } from '@popup/pages/home'; +import { ImportAccountFromLedgerPage } from '@popup/pages/import-account-from-ledger'; import { ImportAccountFromTorusPage } from '@popup/pages/import-account-from-torus'; import { NavigationMenuPageContent } from '@popup/pages/navigation-menu'; import { NftDetailsPage } from '@popup/pages/nft-details'; @@ -26,6 +27,7 @@ import { RateAppPage } from '@popup/pages/rate-app'; import { ReceivePage } from '@popup/pages/receive'; import { RemoveAccountPageContent } from '@popup/pages/remove-account'; import { RenameAccountPageContent } from '@popup/pages/rename-account'; +import { SignWithLedgerInNewWindowPage } from '@popup/pages/sign-with-ledger-in-new-window'; import { StakesPage } from '@popup/pages/stakes'; import { TimeoutPageContent } from '@popup/pages/timeout'; import { TokenDetailPage } from '@popup/pages/token-details'; @@ -283,6 +285,14 @@ function AppRoutes() { element={} /> } /> + } + /> + } + /> ); } diff --git a/src/apps/popup/pages/account-settings/content.tsx b/src/apps/popup/pages/account-settings/content.tsx index 0ba76b291..533b84a94 100644 --- a/src/apps/popup/pages/account-settings/content.tsx +++ b/src/apps/popup/pages/account-settings/content.tsx @@ -12,7 +12,8 @@ import { hideAccountFromListChange } from '@background/redux/vault/actions'; import { selectVaultAccount, selectVaultHiddenAccountsNames, - selectVaultImportedAccountNames + selectVaultImportedAccountNames, + selectVaultLedgerAccountNames } from '@background/redux/vault/selectors'; import { useFetchAccountInfo } from '@hooks/use-fetch-account-info'; @@ -143,6 +144,7 @@ export function AccountSettingsActionsGroup() { const { accountName } = useParams(); const importedAccountNames = useSelector(selectVaultImportedAccountNames); const hiddenAccountsNames = useSelector(selectVaultHiddenAccountsNames); + const ledgerAccountNames = useSelector(selectVaultLedgerAccountNames); if (!accountName) { return null; @@ -150,12 +152,15 @@ export function AccountSettingsActionsGroup() { const isImportedAccount = importedAccountNames.includes(accountName); const isAccountHidden = hiddenAccountsNames.includes(accountName); + const isLedgerAccount = ledgerAccountNames.includes(accountName); return ( - {isImportedAccount && } + {(isImportedAccount || isLedgerAccount) && ( + + )} ); } diff --git a/src/apps/popup/pages/connected-sites/index.tsx b/src/apps/popup/pages/connected-sites/index.tsx index ec39ce68b..66ad56b6e 100644 --- a/src/apps/popup/pages/connected-sites/index.tsx +++ b/src/apps/popup/pages/connected-sites/index.tsx @@ -86,7 +86,7 @@ export function ConnectedSitesPage() { /> )} renderRow={(account, index, array) => { - const { name, publicKey, imported } = account; + const { name, publicKey, imported, hardware } = account; return ( { if (array == null || array.length === 0) { return; diff --git a/src/apps/popup/pages/connected-sites/site-group-item.tsx b/src/apps/popup/pages/connected-sites/site-group-item.tsx index 0abf2fcf1..2e8a720ce 100644 --- a/src/apps/popup/pages/connected-sites/site-group-item.tsx +++ b/src/apps/popup/pages/connected-sites/site-group-item.tsx @@ -5,6 +5,7 @@ import { AlignedSpaceBetweenFlexRow, LeftAlignedFlexColumn } from '@libs/layout'; +import { HardwareWalletType } from '@libs/types/account'; import { Hash, HashVariant, SvgIcon, Typography } from '@libs/ui/components'; const SiteGroupItemContainer = styled(AlignedSpaceBetweenFlexRow)` @@ -21,13 +22,15 @@ interface SiteGroupItemProps { publicKey: string; handleOnClick: () => void; imported?: boolean; + hardware?: HardwareWalletType; } export function SiteGroupItem({ name, publicKey, handleOnClick, - imported + imported, + hardware }: SiteGroupItemProps) { return ( @@ -39,7 +42,8 @@ export function SiteGroupItem({ variant={HashVariant.CaptionHash} value={publicKey} truncated - withTag={imported} + isImported={imported} + isLedger={hardware === HardwareWalletType.Ledger} placement="bottomRight" /> diff --git a/src/apps/popup/pages/download-account-keys/components/account-list-item.tsx b/src/apps/popup/pages/download-account-keys/components/account-list-item.tsx index c02c0b9c6..39ea860d3 100644 --- a/src/apps/popup/pages/download-account-keys/components/account-list-item.tsx +++ b/src/apps/popup/pages/download-account-keys/components/account-list-item.tsx @@ -6,7 +6,7 @@ import { FlexColumn, SpacingSize } from '@libs/layout'; -import { AccountListRows } from '@libs/types/account'; +import { AccountListRows, HardwareWalletType } from '@libs/types/account'; import { Avatar, Checkbox, @@ -88,7 +88,8 @@ export const AccountListItem = ({ variant={HashVariant.CaptionHash} truncated withoutTooltip - withTag={account.imported} + isImported={account.imported} + isLedger={account.hardware === HardwareWalletType.Ledger} /> CSPR diff --git a/src/apps/popup/pages/import-account-from-ledger/connected-ledger.tsx b/src/apps/popup/pages/import-account-from-ledger/connected-ledger.tsx new file mode 100644 index 000000000..59e979464 --- /dev/null +++ b/src/apps/popup/pages/import-account-from-ledger/connected-ledger.tsx @@ -0,0 +1,247 @@ +import { Player } from '@lottiefiles/react-lottie-player'; +import React, { useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; + +import { isEqualCaseInsensitive } from '@src/utils'; + +import { RouterPath, useTypedNavigate } from '@popup/router'; + +import { selectApiConfigBasedOnActiveNetwork } from '@background/redux/settings/selectors'; +import { dispatchToMainStore } from '@background/redux/utils'; +import { accountsImported } from '@background/redux/vault/actions'; + +import { useIsDarkMode } from '@hooks/use-is-dark-mode'; + +import spinnerDarkModeAnimation from '@libs/animations/spinner_dark_mode.json'; +import spinnerLightModeAnimation from '@libs/animations/spinner_light_mode.json'; +import { getAccountHashFromPublicKey } from '@libs/entities/Account'; +import { + CenteredFlexColumn, + ContentContainer, + FooterButtonsContainer, + HeaderPopup, + HeaderSubmenuBarNavLink, + ParagraphContainer, + PopupLayout, + SpacingSize, + VerticalSpaceContainer +} from '@libs/layout'; +import { dispatchFetchAccountBalances } from '@libs/services/balance-service'; +import { + LedgerAccount, + LedgerEventStatus, + ledger +} from '@libs/services/ledger'; +import { Account, HardwareWalletType } from '@libs/types/account'; +import { Button, Tile, Typography } from '@libs/ui/components'; + +import { LedgerAccountsList } from './ledger-accounts-list'; +import { ILedgerAccountListItem } from './types'; + +const AnimationContainer = styled(CenteredFlexColumn)` + padding: 0 16px 52px; +`; + +interface IConnectedLedgerProps { + onClose: () => void; +} + +export const ConnectedLedger: React.FC = ({ + onClose +}) => { + const [isButtonDisabled, setIsButtonDisabled] = useState(true); + const [selectedAccounts, setSelectedAccounts] = useState< + ILedgerAccountListItem[] + >([]); + const [accountsFromLedger, setAccountsFromLedger] = useState( + [] + ); + const [ledgerAccountsWithBalance, setLedgerAccountsWithBalance] = useState< + ILedgerAccountListItem[] + >([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [maxItemsToRender, setMaxItemsToRender] = useState(5); + + const { t } = useTranslation(); + const navigate = useTypedNavigate(); + const isDarkMode = useIsDarkMode(); + + const { casperWalletApiUrl } = useSelector( + selectApiConfigBasedOnActiveNetwork + ); + + useEffect(() => { + ledger.getAccountList({ size: 5, offset: 0 }); + }, []); + + useEffect(() => { + const sub = ledger.subscribeToLedgerEventStatuss(event => { + if (event.status === LedgerEventStatus.AccountListUpdated) { + setAccountsFromLedger(prev => { + return [...prev, ...(event.accounts ?? [])]; + }); + } + }); + + return () => sub.unsubscribe(); + }, []); + + useEffect(() => { + if (!accountsFromLedger.length) return; + + const hashes = accountsFromLedger.reduce( + (previousValue, currentValue, currentIndex) => { + const hash = getAccountHashFromPublicKey(currentValue.publicKey); + + return accountsFromLedger.length === currentIndex + 1 + ? previousValue + `${hash}` + : previousValue + `${hash},`; + }, + '' + ); + + dispatchFetchAccountBalances(hashes) + .then(({ payload }) => { + if ('data' in payload) { + const accountsWithBalance = + accountsFromLedger.map(account => { + const accountWithBalance = payload.data.find(ac => + isEqualCaseInsensitive(ac.public_key, account.publicKey) + ); + + return { + publicKey: account.publicKey, + derivationIndex: account.index, + name: '', + id: account.publicKey, + balance: { + liquidMotes: `${accountWithBalance?.balance ?? '0'}` + } + }; + }); + + setLedgerAccountsWithBalance(accountsWithBalance); + } + }) + .finally(() => { + setIsLoading(false); + setIsLoadingMore(false); + }); + }, [casperWalletApiUrl, accountsFromLedger]); + + const onSubmit = () => { + const accounts: Account[] = selectedAccounts.map(account => ({ + name: account.name, + publicKey: account.publicKey, + secretKey: '', + hardware: HardwareWalletType.Ledger, + hidden: false, + derivationIndex: account.derivationIndex + })); + + dispatchToMainStore(accountsImported(accounts)).then(() => { + onClose(); + navigate(RouterPath.Home); + }); + }; + + const onLoadMore = () => { + try { + setIsLoadingMore(true); + ledger.getAccountList({ + size: 5, + offset: ledgerAccountsWithBalance.length + }); + setMaxItemsToRender(prevState => prevState + 5); + } catch (e) { + setIsLoadingMore(false); + } + }; + + return ( + ( + ( + + )} + /> + )} + renderContent={() => ( + + + + Select accounts + + + + + to connect with Casper Wallet + + + + {isLoading ? ( + + + + + + + Just a moment + + + + Your accounts from Ledger will be here shortly. + + + + + + + ) : ( + + )} + + )} + renderFooter={() => ( + + + + )} + /> + ); +}; diff --git a/src/apps/popup/pages/import-account-from-ledger/index.tsx b/src/apps/popup/pages/import-account-from-ledger/index.tsx new file mode 100644 index 000000000..1b4c301c5 --- /dev/null +++ b/src/apps/popup/pages/import-account-from-ledger/index.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { RouterPath } from '@popup/router'; + +import { useLedger } from '@hooks/use-ledger'; + +import { LedgerEventStatus } from '@libs/services/ledger'; +import { LedgerConnectionView } from '@libs/ui/components'; + +import { ConnectedLedger } from './connected-ledger'; + +export const ImportAccountFromLedgerPage = () => { + const searchParams = new URLSearchParams(document.location.search); + const initialEventToRender = + (searchParams.get('initialEventToRender') as LedgerEventStatus) ?? + LedgerEventStatus.Disconnected; + + const { + ledgerEventStatusToRender, + makeSubmitLedgerAction, + closeNewLedgerWindowsAndClearState + } = useLedger({ + ledgerAction: async () => {}, + shouldLoadAccountList: true, + beforeLedgerActionCb: async () => {}, + initialEventToRender: { status: initialEventToRender }, + withWaitingEventOnDisconnect: false, + askPermissionUrlData: { + domain: 'popup.html', + params: {}, + hash: RouterPath.ImportAccountFromLedger + } + }); + + return ledgerEventStatusToRender.status === + LedgerEventStatus.AccountListUpdated || + ledgerEventStatusToRender.status === + LedgerEventStatus.LoadingAccountsList ? ( + + ) : ( + + ); +}; diff --git a/src/apps/popup/pages/import-account-from-ledger/ledger-accounts-list.tsx b/src/apps/popup/pages/import-account-from-ledger/ledger-accounts-list.tsx new file mode 100644 index 000000000..27e14e6b5 --- /dev/null +++ b/src/apps/popup/pages/import-account-from-ledger/ledger-accounts-list.tsx @@ -0,0 +1,403 @@ +import { Player } from '@lottiefiles/react-lottie-player'; +import React, { useEffect, useState } from 'react'; +import { + Controller, + FieldValues, + useFieldArray, + useForm +} from 'react-hook-form'; +import { Trans, useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; + +import { isEqualCaseInsensitive } from '@src/utils'; + +import { + selectVaultAccountsNames, + selectVaultLedgerAccounts +} from '@background/redux/vault/selectors'; + +import { useIsDarkMode } from '@hooks/use-is-dark-mode'; + +import dotsDarkModeAnimation from '@libs/animations/dots_dark_mode.json'; +import dotsLightModeAnimation from '@libs/animations/dots_light_mode.json'; +import { + CenteredFlexRow, + FlexColumn, + LeftAlignedCenteredFlexRow, + SpaceBetweenFlexRow, + SpacingSize +} from '@libs/layout'; +import { + Avatar, + Checkbox, + Hash, + HashVariant, + Input, + List, + Tooltip, + Typography +} from '@libs/ui/components'; +import { calculateSubmitButtonDisabled } from '@libs/ui/forms/get-submit-button-state-from-validation'; +import { formatNumber, motesToCSPR } from '@libs/ui/utils'; + +import { ILedgerAccountListItem } from './types'; + +const ListItemContainer = styled(FlexColumn)<{ disabled?: boolean }>` + padding: 20px 16px; + + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; +`; +const FooterContainer = styled(LeftAlignedCenteredFlexRow)` + padding: 18px 16px; +`; +const MoreItem = styled(Typography)` + cursor: pointer; +`; +const AmountContainer = styled(FlexColumn)` + max-width: 90px; +`; + +interface ListProps { + ledgerAccountsWithBalance: ILedgerAccountListItem[]; + setIsButtonDisabled: React.Dispatch>; + selectedAccounts: ILedgerAccountListItem[]; + setSelectedAccounts: React.Dispatch< + React.SetStateAction + >; + maxItemsToRender: number; + onLoadMore: () => void; + isLoadingMore: boolean; +} + +type FormFields = FieldValues & { + accountNames: { name: string }[]; + checkbox: boolean[]; +}; + +export const LedgerAccountsList = ({ + ledgerAccountsWithBalance, + setIsButtonDisabled, + selectedAccounts, + setSelectedAccounts, + maxItemsToRender, + onLoadMore, + isLoadingMore +}: ListProps) => { + const [accountNames, setAccountNames] = useState<{ name: string }[]>([]); + const [checkboxes, setCheckboxes] = useState([]); + + const { t } = useTranslation(); + const isDarkMode = useIsDarkMode(); + + const alreadyConnectedLedgerAccounts = useSelector(selectVaultLedgerAccounts); + const existingAccountNames = useSelector(selectVaultAccountsNames); + + const { + control, + formState: { isValid }, + getValues, + trigger + } = useForm({ + defaultValues: { + accountNames: [], + checkbox: [] + }, + mode: 'onChange', + reValidateMode: 'onChange' + }); + + const { fields: inputsFields, append } = useFieldArray({ + name: 'accountNames', + control + }); + + useEffect(() => { + for ( + let i = inputsFields.length; + i < ledgerAccountsWithBalance.length; + i++ + ) { + append({ name: `Ledger account ${i + 1}` }); + } + }, [append, inputsFields.length, ledgerAccountsWithBalance]); + + useEffect(() => { + setAccountNames(getValues('accountNames')); + setCheckboxes(getValues('checkbox')); + }, [getValues]); + + const handleInputChange = (id: string, newValue: string) => { + // Update the state with the new value + setSelectedAccounts(prevItems => + prevItems.map(item => + isEqualCaseInsensitive(item.id, id) + ? { ...item, name: newValue.trim() } + : item + ) + ); + }; + + useEffect(() => { + const isButtonDisabled = calculateSubmitButtonDisabled({ + isValid + }); + + setIsButtonDisabled(!!isButtonDisabled); + }, [isValid, setIsButtonDisabled]); + + return ( + + rows={ledgerAccountsWithBalance} + contentTop={SpacingSize.XL} + maxItemsToRender={maxItemsToRender} + renderRow={(account, index) => { + const inputFieldName = `accountNames.${index}.name`; + const checkBoxFieldName = `checkbox.${index}`; + const balance = formatNumber( + motesToCSPR(String(account.balance.liquidMotes)), + { + precision: { max: 0 } + } + ); + + const isAlreadyConnected = alreadyConnectedLedgerAccounts.some( + alreadyConnectedAccount => + isEqualCaseInsensitive( + alreadyConnectedAccount.publicKey, + account.publicKey + ) + ); + + const checkboxValue = getValues(checkBoxFieldName); + + return ( + + ( + { + if (isAlreadyConnected) return; + + const accountIndex = selectedAccounts.findIndex( + alreadySelectedAccount => + isEqualCaseInsensitive( + alreadySelectedAccount.id, + account.id + ) + ); + const accountName: string = getValues(inputFieldName); + + let updatedAccounts; + if (accountIndex !== -1) { + // Account exists, remove from list: + updatedAccounts = selectedAccounts.filter( + alreadySelectedAccount => + alreadySelectedAccount.id !== account.id + ); + } else { + // Account doesn't exist, add to list: + updatedAccounts = selectedAccounts.concat({ + ...account, + name: accountName + }); + } + + setSelectedAccounts(updatedAccounts); + checkboxControllerField.onChange( + !checkboxControllerField.value + ); + + trigger(); + }} + > + + + + + + + 9 ? balance : undefined} + placement="topLeft" + overflowWrap + fullWidth + > + + {balance} + + + + CSPR + + + + + + )} + name={checkBoxFieldName} + /> + {(checkboxValue || isAlreadyConnected) && + inputsFields.map((inputField, inputFieldIndex) => + inputFieldIndex === index ? ( + { + return ( + { + inputControllerField.onChange(event); + + // manually trigger validation in case when a few inputs have the same name + // and user change one of them. + // So we validate all of them to remove error from the fields. + // This is an edge case. + trigger().then(isValid => { + if (isValid) { + handleInputChange( + account.id, + event.target.value + ); + } + }); + }} + error={ + !!inputControllerFormState.errors.accountNames?.[ + inputFieldIndex + ]?.name + } + validationText={ + inputControllerFormState.errors.accountNames?.[ + inputFieldIndex + ]?.name?.message + } + /> + ); + }} + control={control} + name={`accountNames.${inputFieldIndex}.name`} + rules={{ + pattern: { + value: /^[\daA-zZ\s]+$/, + message: t( + 'Account name can’t contain special characters' + ) + }, + validate: + checkboxValue && !isAlreadyConnected + ? { + noEmptyInput: value => + (value != null && value.trim() !== '') || + t("Name can't be empty"), + maxLength: value => + value.length <= 20 || + t( + "Account name can't be longer than 20 characters" + ), + unique: value => { + // Filter the inputs of 'accountNames' to only leave those where the checkbox is checked + // and the field index doesn't match the current input field index. + // This leaves us with an array of inputs that are selected + // (checked) and not the one being validated. + const onlyCheckedInputs = accountNames + .map((input, index) => + checkboxes[index] ? input : null + ) + .filter( + (input, index) => + index !== inputFieldIndex && + input !== null + ); + + // Checks to see if the current value exists within the selected inputs. + // The `some` function will return true as soon as it finds a value that matches, + // hence it will return false if the name is unique. + const isUnique = !onlyCheckedInputs.some( + input => input?.name === value + ); + + // Checks if the current value exists in the 'existingAccountNames' array. + const isNotInExistingAccountNames = + !existingAccountNames.includes(value); + + // Returns the validation results. + // If the entered account name is both unique and not in the existing account names array, + // it returns true (passing validation), + // otherwise it returns the error message. + return ( + (isUnique && isNotInExistingAccountNames) || + t('Account name is already taken') + ); + } + } + : undefined + }} + /> + ) : null + )} + + ); + }} + marginLeftForItemSeparatorLine={56} + renderFooter={() => + isLoadingMore ? ( + + ) : ( + + + Show next 5 accounts + + + ) + } + /> + ); +}; diff --git a/src/apps/popup/pages/import-account-from-ledger/types.ts b/src/apps/popup/pages/import-account-from-ledger/types.ts new file mode 100644 index 000000000..bae38cb3e --- /dev/null +++ b/src/apps/popup/pages/import-account-from-ledger/types.ts @@ -0,0 +1,6 @@ +import { AccountWithBalance } from '@libs/types/account'; + +export type ILedgerAccountListItem = Omit< + AccountWithBalance, + 'hidden' | 'secretKey' | 'imported' | 'hardware' +> & { id: string }; diff --git a/src/apps/popup/pages/navigation-menu/index.tsx b/src/apps/popup/pages/navigation-menu/index.tsx index 42c9aea4c..fb410738e 100644 --- a/src/apps/popup/pages/navigation-menu/index.tsx +++ b/src/apps/popup/pages/navigation-menu/index.tsx @@ -1,13 +1,12 @@ import React, { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; -import { isSafariBuild } from '@src/utils'; +import { isLedgerAvailable, isSafariBuild } from '@src/utils'; import { TimeoutDurationSetting } from '@popup/constants'; -import { RouterPath, useNavigationMenu } from '@popup/router'; +import { RouterPath, useNavigationMenu, useTypedNavigate } from '@popup/router'; import { WindowApp } from '@background/create-open-window'; import { selectCountOfContacts } from '@background/redux/contacts/selectors'; @@ -82,7 +81,7 @@ interface MenuGroup { } export function NavigationMenuPageContent() { - const navigate = useNavigate(); + const navigate = useTypedNavigate(); const { t } = useTranslation(); const timeoutDurationSetting = useSelector(selectTimeoutDurationSetting); @@ -163,7 +162,21 @@ export function NavigationMenuPageContent() { closeNavigationMenu(); navigate(RouterPath.ImportAccountFromTorus); } - } + }, + ...(isLedgerAvailable + ? [ + { + id: 5, + title: t('Connect Ledger'), + iconPath: 'assets/icons/ledger-blue.svg', + disabled: false, + handleOnClick: () => { + closeNavigationMenu(); + navigate(RouterPath.ImportAccountFromLedger); + } + } + ] + : []) ] }, { diff --git a/src/apps/popup/pages/sign-with-ledger-in-new-window/index.tsx b/src/apps/popup/pages/sign-with-ledger-in-new-window/index.tsx new file mode 100644 index 000000000..8f3b04e16 --- /dev/null +++ b/src/apps/popup/pages/sign-with-ledger-in-new-window/index.tsx @@ -0,0 +1,88 @@ +import { DeployUtil } from 'casper-js-sdk'; +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { fetchAndDispatchExtendedDeployInfo } from '@src/utils'; + +import { + selectLedgerDeploy, + selectLedgerRecipientToSaveOnSuccess +} from '@background/redux/ledger/selectors'; +import { recipientPublicKeyAdded } from '@background/redux/recent-recipient-public-keys/actions'; +import { selectApiConfigBasedOnActiveNetwork } from '@background/redux/settings/selectors'; +import { dispatchToMainStore } from '@background/redux/utils'; +import { selectVaultActiveAccount } from '@background/redux/vault/selectors'; + +import { useLedger } from '@hooks/use-ledger'; + +import { createAsymmetricKey } from '@libs/crypto/create-asymmetric-key'; +import { sendSignDeploy, signDeploy } from '@libs/services/deployer-service'; +import { LedgerEventStatus } from '@libs/services/ledger'; +import { LedgerConnectionView } from '@libs/ui/components'; + +import { SuccessView } from './success-view'; + +export const SignWithLedgerInNewWindowPage = () => { + const deploy = useSelector(selectLedgerDeploy); + const recipient = useSelector(selectLedgerRecipientToSaveOnSuccess); + const activeAccount = useSelector(selectVaultActiveAccount); + const { nodeUrl } = useSelector(selectApiConfigBasedOnActiveNetwork); + const [isSuccess, setIsSuccess] = useState(false); + + const ledgerAction = async () => { + if (!(activeAccount && deploy)) { + return; + } + + const KEYS = createAsymmetricKey( + activeAccount.publicKey, + activeAccount.secretKey + ); + + const resp = DeployUtil.deployFromJson(JSON.parse(deploy)); + + if (!resp.ok) { + console.log('-------- json parse error', resp.val); + return; + } + + const signedDeploy = await signDeploy(resp.val, [KEYS], activeAccount); + + sendSignDeploy(signedDeploy, nodeUrl) + .then(resp => { + if (recipient) { + dispatchToMainStore(recipientPublicKeyAdded(recipient)); + } + + if ('result' in resp) { + fetchAndDispatchExtendedDeployInfo(resp.result.deploy_hash); + } + + setIsSuccess(true); + }) + .catch(error => { + console.error(error, 'transfer request error'); + }); + }; + + const { + ledgerEventStatusToRender, + makeSubmitLedgerAction, + closeNewLedgerWindowsAndClearState + } = useLedger({ + ledgerAction, + beforeLedgerActionCb: async () => {}, + initialEventToRender: { status: LedgerEventStatus.LedgerAskPermission }, + withWaitingEventOnDisconnect: false + }); + + return isSuccess ? ( + + ) : ( + + ); +}; diff --git a/src/apps/popup/pages/sign-with-ledger-in-new-window/success-view.tsx b/src/apps/popup/pages/sign-with-ledger-in-new-window/success-view.tsx new file mode 100644 index 000000000..ee84b47c3 --- /dev/null +++ b/src/apps/popup/pages/sign-with-ledger-in-new-window/success-view.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { + ContentContainer, + FooterButtonsContainer, + HeaderPopup, + ParagraphContainer, + PopupLayout, + SpacingSize, + VerticalSpaceContainer +} from '@libs/layout'; +import { Button, SvgIcon, Typography } from '@libs/ui/components'; + +interface ISuccessViewProps { + onClose: () => void; +} + +export const SuccessView: React.FC = ({ onClose }) => { + const { t } = useTranslation(); + + return ( + ( + + )} + renderContent={() => ( + + + + + + Deploy successfully sent + + + + + + You can close this window and continue to use extension + + + + + + )} + renderFooter={() => ( + + + + )} + /> + ); +}; diff --git a/src/apps/popup/pages/stakes/content.tsx b/src/apps/popup/pages/stakes/content.tsx index 2cc376506..5492eb9ef 100644 --- a/src/apps/popup/pages/stakes/content.tsx +++ b/src/apps/popup/pages/stakes/content.tsx @@ -17,8 +17,13 @@ import { SpacingSize, VerticalSpaceContainer } from '@libs/layout'; +import { ILedgerEvent } from '@libs/services/ledger'; import { ValidatorResultWithId } from '@libs/services/validators-service/types'; -import { TransferSuccessScreen, Typography } from '@libs/ui/components'; +import { + LedgerEventView, + TransferSuccessScreen, + Typography +} from '@libs/ui/components'; import { StakeAmountFormValues, StakeNewValidatorFormValues, @@ -45,6 +50,7 @@ interface DelegateStakePageContentProps { validatorList: ValidatorResultWithId[] | null; undelegateValidatorList: ValidatorResultWithId[] | null; loading: boolean; + LedgerEventStatus: ILedgerEvent; } export const StakesPageContent = ({ @@ -62,7 +68,8 @@ export const StakesPageContent = ({ setStakeAmount, validatorList, undelegateValidatorList, - loading + loading, + LedgerEventStatus }: DelegateStakePageContentProps) => { const { t } = useTranslation(); @@ -77,89 +84,81 @@ export const StakesPageContent = ({ amountStepMaxAmountValue } = useStakeActionTexts(stakesType, stakeAmountMotes); - switch (stakeStep) { - case StakeSteps.Validator: { - return ( - - - - ); - } - case StakeSteps.Amount: { - return ( - - - - ); - } - case StakeSteps.NewValidator: { - return ( - - - - - Amount: - - {`${inputAmountCSPR} CSPR`} - - - - - ); - } - case StakeSteps.Confirm: { - return ( - - - - ); - } - case StakeSteps.Success: { - return ( - - {stakesType === AuctionManagerEntryPoint.redelegate ? ( - - - - I usually takes around{' '} - 14 to 16 hours{' '} - for this operation to complete. - - - - ) : null} - - ); - } - default: { - throw Error('Out of bound: StakeSteps'); - } - } + const getContent = { + [StakeSteps.Validator]: ( + + + + ), + [StakeSteps.Amount]: ( + + + + ), + [StakeSteps.NewValidator]: ( + + + + + Amount: + + {`${inputAmountCSPR} CSPR`} + + + + + ), + [StakeSteps.Confirm]: ( + + + + ), + [StakeSteps.ConfirmWithLedger]: ( + + ), + [StakeSteps.Success]: ( + + {stakesType === AuctionManagerEntryPoint.redelegate ? ( + + + + I usually takes around{' '} + 14 to 16 hours for + this operation to complete. + + + + ) : null} + + ) + }; + + return getContent[stakeStep]; }; diff --git a/src/apps/popup/pages/stakes/index.tsx b/src/apps/popup/pages/stakes/index.tsx index ea1907278..698153f33 100644 --- a/src/apps/popup/pages/stakes/index.tsx +++ b/src/apps/popup/pages/stakes/index.tsx @@ -1,3 +1,4 @@ +import { DeployUtil } from 'casper-js-sdk'; import React, { useEffect, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -15,33 +16,49 @@ import { useConfirmationButtonText } from '@popup/pages/stakes/utils'; import { RouterPath, useTypedLocation, useTypedNavigate } from '@popup/router'; import { selectAccountBalance } from '@background/redux/account-info/selectors'; +import { ledgerDeployChanged } from '@background/redux/ledger/actions'; import { selectAskForReviewAfter, selectRatedInStore } from '@background/redux/rate-app/selectors'; import { selectApiConfigBasedOnActiveNetwork } from '@background/redux/settings/selectors'; -import { selectVaultActiveAccount } from '@background/redux/vault/selectors'; +import { dispatchToMainStore } from '@background/redux/utils'; +import { + selectIsActiveAccountFromLedger, + selectVaultActiveAccount +} from '@background/redux/vault/selectors'; + +import { useLedger } from '@hooks/use-ledger'; import { createAsymmetricKey } from '@libs/crypto/create-asymmetric-key'; import { + AlignedFlexRow, ErrorPath, FooterButtonsContainer, HeaderPopup, HeaderSubmenuBarNavLink, PopupLayout, SpaceBetweenFlexRow, + SpacingSize, createErrorLocationState } from '@libs/layout'; import { - makeAuctionManagerDeployAndSing, - sendSignDeploy + makeAuctionManagerDeploy, + sendSignDeploy, + signDeploy } from '@libs/services/deployer-service'; import { dispatchFetchAuctionValidatorsRequest, dispatchFetchValidatorsDetailsDataRequest } from '@libs/services/validators-service'; import { ValidatorResultWithId } from '@libs/services/validators-service/types'; -import { Button, HomePageTabsId, Typography } from '@libs/ui/components'; +import { + Button, + HomePageTabsId, + SvgIcon, + Typography, + renderLedgerFooter +} from '@libs/ui/components'; import { calculateSubmitButtonDisabled } from '@libs/ui/forms/get-submit-button-state-from-validation'; import { useStakesForm } from '@libs/ui/forms/stakes-form'; import { CSPRtoMotes, formatNumber, motesToCSPR } from '@libs/ui/utils'; @@ -70,6 +87,9 @@ export const StakesPage = () => { const [loading, setLoading] = useState(true); const activeAccount = useSelector(selectVaultActiveAccount); + const isActiveAccountFromLedger = useSelector( + selectIsActiveAccountFromLedger + ); const { networkName, nodeUrl, @@ -229,7 +249,7 @@ export const StakesPage = () => { activeAccount.secretKey ); - const signDeploy = await makeAuctionManagerDeployAndSing( + const deploy = await makeAuctionManagerDeploy( stakesType, activeAccount.publicKey, validatorPublicKey, @@ -237,11 +257,12 @@ export const StakesPage = () => { motesAmount, networkName, auctionManagerContractHash, - nodeUrl, - [KEYS] + nodeUrl ); - sendSignDeploy(signDeploy, nodeUrl) + const signedDeploy = await signDeploy(deploy, [KEYS], activeAccount); + + sendSignDeploy(signedDeploy, nodeUrl) .then(resp => { if ('result' in resp) { fetchAndDispatchExtendedDeployInfo(resp.result.deploy_hash); @@ -285,6 +306,34 @@ export const StakesPage = () => { } }; + const beforeLedgerActionCb = async () => { + setStakeStep(StakeSteps.ConfirmWithLedger); + + if (activeAccount) { + const motesAmount = CSPRtoMotes(inputAmountCSPR); + + const deploy = await makeAuctionManagerDeploy( + stakesType, + activeAccount.publicKey, + validatorPublicKey, + newValidatorPublicKey || null, + motesAmount, + networkName, + auctionManagerContractHash, + nodeUrl + ); + + dispatchToMainStore( + ledgerDeployChanged(JSON.stringify(DeployUtil.deployToJson(deploy))) + ); + } + }; + + const { ledgerEventStatusToRender, makeSubmitLedgerAction } = useLedger({ + ledgerAction: submitStake, + beforeLedgerActionCb + }); + const getButtonProps = () => { const isValidatorFormButtonDisabled = calculateSubmitButtonDisabled({ isValid: validatorFormState.isValid @@ -341,7 +390,9 @@ export const StakesPage = () => { isSubmitButtonDisable || isValidatorFormButtonDisabled || isAmountFormButtonDisabled, - onClick: submitStake + onClick: isActiveAccountFromLedger + ? makeSubmitLedgerAction() + : submitStake }; } case StakeSteps.Success: { @@ -373,34 +424,46 @@ export const StakesPage = () => { } }; - const handleBackButton = () => { - switch (stakeStep) { - case StakeSteps.Validator: { - navigate(-1); - break; - } - case StakeSteps.Amount: { - setStakeStep(StakeSteps.Validator); - break; - } - case StakeSteps.NewValidator: { - setStakeStep(StakeSteps.Amount); - break; - } - case StakeSteps.Confirm: { - if (stakesType === AuctionManagerEntryPoint.redelegate) { - setStakeStep(StakeSteps.NewValidator); - } else { - setStakeStep(StakeSteps.Amount); + const getBackButton = { + [StakeSteps.Validator]: () => ( + navigate(-1)} + /> + ), + [StakeSteps.Amount]: () => ( + setStakeStep(StakeSteps.Validator)} + /> + ), + [StakeSteps.NewValidator]: () => ( + setStakeStep(StakeSteps.Amount)} + /> + ), + [StakeSteps.Confirm]: () => ( + + stakesType === AuctionManagerEntryPoint.redelegate + ? setStakeStep(StakeSteps.NewValidator) + : setStakeStep(StakeSteps.Amount) } - break; - } - - default: { - navigate(-1); - break; - } - } + /> + ), + [StakeSteps.ConfirmWithLedger]: () => ( + setStakeStep(StakeSteps.Confirm)} + /> + ), + [StakeSteps.Success]: undefined }; const confirmButtonText = useConfirmationButtonText(stakesType); @@ -431,6 +494,50 @@ export const StakesPage = () => { ); } + const renderFooter = () => { + if (stakeStep === StakeSteps.ConfirmWithLedger) { + return renderLedgerFooter({ + onConnect: makeSubmitLedgerAction, + event: ledgerEventStatusToRender, + onErrorCtaPressed: () => setStakeStep(StakeSteps.Confirm) + }); + } + + return () => ( + + {stakeStep === StakeSteps.Amount ? ( + + + Transaction fee + + + {formatNumber(motesToCSPR(STAKE_COST_MOTES), { + precision: { max: 5 } + })}{' '} + CSPR + + + ) : null} + + + ); + }; + return ( ( @@ -438,17 +545,7 @@ export const StakesPage = () => { withNetworkSwitcher withMenu withConnectionStatus - renderSubmenuBarItems={ - stakeStep === StakeSteps.Success - ? undefined - : () => ( - - ) - } + renderSubmenuBarItems={getBackButton[stakeStep]} /> )} renderContent={() => ( @@ -468,34 +565,10 @@ export const StakesPage = () => { validatorList={validatorList} undelegateValidatorList={undelegateValidatorList} loading={loading} + LedgerEventStatus={ledgerEventStatusToRender} /> )} - renderFooter={() => ( - - {stakeStep === StakeSteps.Amount ? ( - - - Transaction fee - - - {formatNumber(motesToCSPR(STAKE_COST_MOTES), { - precision: { max: 5 } - })}{' '} - CSPR - - - ) : null} - - - )} + renderFooter={renderFooter()} /> ); }; diff --git a/src/apps/popup/pages/transfer-nft/index.tsx b/src/apps/popup/pages/transfer-nft/index.tsx index 63b51b7ab..d28cc16ad 100644 --- a/src/apps/popup/pages/transfer-nft/index.tsx +++ b/src/apps/popup/pages/transfer-nft/index.tsx @@ -1,3 +1,4 @@ +import { DeployUtil } from 'casper-js-sdk'; import React, { useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -21,6 +22,10 @@ import { selectAccountNftTokens } from '@background/redux/account-info/selectors'; import { selectAllPublicKeys } from '@background/redux/contacts/selectors'; +import { + ledgerDeployChanged, + ledgerRecipientToSaveOnSuccessChanged +} from '@background/redux/ledger/actions'; import { selectAskForReviewAfter, selectRatedInStore @@ -28,26 +33,37 @@ import { import { recipientPublicKeyAdded } from '@background/redux/recent-recipient-public-keys/actions'; import { selectApiConfigBasedOnActiveNetwork } from '@background/redux/settings/selectors'; import { dispatchToMainStore } from '@background/redux/utils'; -import { selectVaultActiveAccount } from '@background/redux/vault/selectors'; +import { + selectIsActiveAccountFromLedger, + selectVaultActiveAccount +} from '@background/redux/vault/selectors'; + +import { useLedger } from '@hooks/use-ledger'; import { createAsymmetricKey } from '@libs/crypto/create-asymmetric-key'; import { getRawPublicKey } from '@libs/entities/Account'; import { + AlignedFlexRow, ErrorPath, FooterButtonsContainer, HeaderPopup, HeaderSubmenuBarNavLink, PopupLayout, + SpacingSize, createErrorLocationState } from '@libs/layout'; import { - makeNFTDeployAndSign, - sendSignDeploy + makeNFTDeploy, + sendSignDeploy, + signDeploy } from '@libs/services/deployer-service'; import { Button, HomePageTabsId, - TransferSuccessScreen + LedgerEventView, + SvgIcon, + TransferSuccessScreen, + renderLedgerFooter } from '@libs/ui/components'; import { calculateSubmitButtonDisabled } from '@libs/ui/forms/get-submit-button-state-from-validation'; import { useTransferNftForm } from '@libs/ui/forms/transfer-nft'; @@ -56,11 +72,16 @@ import { CSPRtoMotes } from '@libs/ui/utils'; export const TransferNftPage = () => { const [showSuccessScreen, setShowSuccessScreen] = useState(false); const [haveReverseOwnerLookUp, setHaveReverseOwnerLookUp] = useState(false); + const [showLedgerConfirm, setShowLedgerConfirm] = useState(false); + const { contractPackageHash, tokenId } = useParams(); const nftTokens = useSelector(selectAccountNftTokens); const csprBalance = useSelector(selectAccountBalance); const activeAccount = useSelector(selectVaultActiveAccount); + const isActiveAccountFromLedger = useSelector( + selectIsActiveAccountFromLedger + ); const { networkName, nodeUrl } = useSelector( selectApiConfigBasedOnActiveNetwork ); @@ -141,17 +162,18 @@ export const TransferNftPage = () => { target: getRawPublicKey(recipientPublicKey) }; - const signDeploy = await makeNFTDeployAndSign( + const deploy = await makeNFTDeploy( getRuntimeArgs(tokenStandard, args), CSPRtoMotes(paymentAmount), KEYS.publicKey, networkName, nftToken?.contract_package_hash!, - nodeUrl, - [KEYS] + nodeUrl ); - sendSignDeploy(signDeploy, nodeUrl) + const signedDeploy = await signDeploy(deploy, [KEYS], activeAccount); + + sendSignDeploy(signedDeploy, nodeUrl) .then(resp => { dispatchToMainStore(recipientPublicKeyAdded(recipientPublicKey)); @@ -206,6 +228,127 @@ export const TransferNftPage = () => { } }; + const beforeLedgerActionCb = async () => { + setShowLedgerConfirm(true); + + if (haveReverseOwnerLookUp || !nftToken || !activeAccount) return; + + const KEYS = createAsymmetricKey( + activeAccount.publicKey, + activeAccount.secretKey + ); + + const args = { + tokenId: nftToken.token_id, + source: KEYS.publicKey, + target: getRawPublicKey(recipientPublicKey) + }; + + const deploy = await makeNFTDeploy( + getRuntimeArgs(tokenStandard, args), + CSPRtoMotes(paymentAmount), + KEYS.publicKey, + networkName, + nftToken?.contract_package_hash!, + nodeUrl + ); + + dispatchToMainStore( + ledgerDeployChanged(JSON.stringify(DeployUtil.deployToJson(deploy))) + ); + dispatchToMainStore( + ledgerRecipientToSaveOnSuccessChanged(recipientPublicKey) + ); + }; + + const { ledgerEventStatusToRender, makeSubmitLedgerAction } = useLedger({ + ledgerAction: submitTransfer, + beforeLedgerActionCb + }); + + const renderFooter = () => { + if (showLedgerConfirm && !showSuccessScreen) { + return renderLedgerFooter({ + onConnect: makeSubmitLedgerAction, + event: ledgerEventStatusToRender, + onErrorCtaPressed: () => setShowLedgerConfirm(false) + }); + } + + return () => ( + + {showSuccessScreen ? ( + <> + + + {!isRecipientPublicKeyInContact && ( + + )} + + ) : ( + + )} + + ); + }; + return ( ( @@ -216,13 +359,22 @@ export const TransferNftPage = () => { renderSubmenuBarItems={ showSuccessScreen ? undefined - : () => + : showLedgerConfirm + ? () => ( + setShowLedgerConfirm(false)} + /> + ) + : () => } /> )} renderContent={() => showSuccessScreen ? ( + ) : showLedgerConfirm ? ( + ) : ( { /> ) } - renderFooter={() => ( - - {showSuccessScreen ? ( - <> - - - {!isRecipientPublicKeyInContact && ( - - )} - - ) : ( - - )} - - )} + renderFooter={renderFooter()} /> ); }; diff --git a/src/apps/popup/pages/transfer/content.tsx b/src/apps/popup/pages/transfer/content.tsx index 2e171dba1..5b3d1d7bf 100644 --- a/src/apps/popup/pages/transfer/content.tsx +++ b/src/apps/popup/pages/transfer/content.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; import { UseFormReturn } from 'react-hook-form'; -import { TransferSuccessScreen } from '@libs/ui/components'; +import { ILedgerEvent } from '@libs/services/ledger'; +import { LedgerEventView, TransferSuccessScreen } from '@libs/ui/components'; import { TransferAmountFormValues, TransferRecipientFormValues @@ -21,6 +22,7 @@ interface TransferPageContentProps { balance: string | null; symbol: string | null; paymentAmount: string; + LedgerEventStatus: ILedgerEvent; } export const TransferPageContent = ({ @@ -31,49 +33,44 @@ export const TransferPageContent = ({ amount, balance, symbol, - paymentAmount + paymentAmount, + LedgerEventStatus }: TransferPageContentProps) => { const [recipientName, setRecipientName] = useState(''); const isCSPR = symbol === 'CSPR'; - switch (transferStep) { - case TransactionSteps.Recipient: { - return ( - - ); - } - case TransactionSteps.Amount: { - return ( - - ); - } - case TransactionSteps.Confirm: { - return ( - - ); - } + const getContent = { + [TransactionSteps.Recipient]: ( + + ), + [TransactionSteps.Amount]: ( + + ), + [TransactionSteps.Confirm]: ( + + ), + [TransactionSteps.ConfirmWithLedger]: ( + + ), + [TransactionSteps.Success]: ( + + ) + }; - case TransactionSteps.Success: { - return ; - } - - default: { - throw Error('Out of bound: TransactionSteps'); - } - } + return getContent[transferStep]; }; diff --git a/src/apps/popup/pages/transfer/index.tsx b/src/apps/popup/pages/transfer/index.tsx index ba5c7bb9c..0f83091e0 100644 --- a/src/apps/popup/pages/transfer/index.tsx +++ b/src/apps/popup/pages/transfer/index.tsx @@ -16,6 +16,10 @@ import { RouterPath, useTypedLocation, useTypedNavigate } from '@popup/router'; import { selectAccountBalance } from '@background/redux/account-info/selectors'; import { selectAllPublicKeys } from '@background/redux/contacts/selectors'; +import { + ledgerDeployChanged, + ledgerRecipientToSaveOnSuccessChanged +} from '@background/redux/ledger/actions'; import { selectAskForReviewAfter, selectRatedInStore @@ -23,24 +27,39 @@ import { import { recipientPublicKeyAdded } from '@background/redux/recent-recipient-public-keys/actions'; import { selectApiConfigBasedOnActiveNetwork } from '@background/redux/settings/selectors'; import { dispatchToMainStore } from '@background/redux/utils'; -import { selectVaultActiveAccount } from '@background/redux/vault/selectors'; +import { + selectIsActiveAccountFromLedger, + selectVaultActiveAccount +} from '@background/redux/vault/selectors'; + +import { useLedger } from '@hooks/use-ledger'; import { createAsymmetricKey } from '@libs/crypto/create-asymmetric-key'; import { + AlignedFlexRow, ErrorPath, FooterButtonsContainer, HeaderPopup, HeaderSubmenuBarNavLink, PopupLayout, SpaceBetweenFlexRow, + SpacingSize, createErrorLocationState } from '@libs/layout'; import { - makeCep18TransferDeployAndSign, - makeNativeTransferDeployAndSign, - sendSignDeploy + makeCep18TransferDeploy, + makeNativeTransferDeploy, + sendSignDeploy, + signDeploy } from '@libs/services/deployer-service'; -import { Button, HomePageTabsId, Typography } from '@libs/ui/components'; +import { HardwareWalletType } from '@libs/types/account'; +import { + Button, + HomePageTabsId, + SvgIcon, + Typography, + renderLedgerFooter +} from '@libs/ui/components'; import { calculateSubmitButtonDisabled } from '@libs/ui/forms/get-submit-button-state-from-validation'; import { useTransferForm } from '@libs/ui/forms/transfer'; import { @@ -73,6 +92,9 @@ export const TransferPage = () => { const [isSubmitButtonDisable, setIsSubmitButtonDisable] = useState(true); const activeAccount = useSelector(selectVaultActiveAccount); + const isActiveAccountFromLedger = useSelector( + selectIsActiveAccountFromLedger + ); const { networkName, nodeUrl } = useSelector( selectApiConfigBasedOnActiveNetwork ); @@ -226,9 +248,10 @@ export const TransferPage = () => { activeAccount.publicKey, activeAccount.secretKey ); + if (isErc20Transfer) { // ERC20 transfer - const signDeploy = await makeCep18TransferDeployAndSign( + const deploy = await makeCep18TransferDeploy( nodeUrl, networkName, tokenContractHash, @@ -237,30 +260,82 @@ export const TransferPage = () => { amount, erc20Decimals, paymentAmount, - activeAccount, - [KEYS] + activeAccount ); - sendDeploy(signDeploy); + const signedDeploy = await signDeploy(deploy, [KEYS], activeAccount); + + sendDeploy(signedDeploy); } else { // CSPR transfer const motesAmount = CSPRtoMotes(amount); - const signDeploy = await makeNativeTransferDeployAndSign( - activeAccount.publicKey, + const deploy = await makeNativeTransferDeploy( + activeAccount, recipientPublicKey, motesAmount, networkName, nodeUrl, - [KEYS], transferIdMemo ); - sendDeploy(signDeploy); + const signedDeploy = await signDeploy(deploy, [KEYS], activeAccount); + + sendDeploy(signedDeploy); } } }; + const beforeLedgerActionCb = async () => { + setTransferStep(TransactionSteps.ConfirmWithLedger); + + if (activeAccount?.hardware === HardwareWalletType.Ledger) { + if (isErc20Transfer) { + const deploy = await makeCep18TransferDeploy( + nodeUrl, + networkName, + tokenContractHash, + tokenContractPackageHash, + recipientPublicKey, + amount, + erc20Decimals, + paymentAmount, + activeAccount + ); + + dispatchToMainStore( + ledgerDeployChanged(JSON.stringify(DeployUtil.deployToJson(deploy))) + ); + dispatchToMainStore( + ledgerRecipientToSaveOnSuccessChanged(recipientPublicKey) + ); + } else { + const motesAmount = CSPRtoMotes(amount); + + const deploy = await makeNativeTransferDeploy( + activeAccount, + recipientPublicKey, + motesAmount, + networkName, + nodeUrl, + transferIdMemo + ); + + dispatchToMainStore( + ledgerDeployChanged(JSON.stringify(DeployUtil.deployToJson(deploy))) + ); + dispatchToMainStore( + ledgerRecipientToSaveOnSuccessChanged(recipientPublicKey) + ); + } + } + }; + + const { ledgerEventStatusToRender, makeSubmitLedgerAction } = useLedger({ + ledgerAction: onSubmitSending, + beforeLedgerActionCb + }); + const getButtonProps = () => { const isRecipientFormButtonDisabled = calculateSubmitButtonDisabled({ isValid: recipientFormState.isValid @@ -304,7 +379,9 @@ export const TransferPage = () => { isSubmitButtonDisable || isRecipientFormButtonDisabled || isAmountFormButtonDisabled, - onClick: onSubmitSending + onClick: isActiveAccountFromLedger + ? makeSubmitLedgerAction() + : onSubmitSending }; } case TransactionSteps.Success: { @@ -335,64 +412,48 @@ export const TransferPage = () => { } }; - const handleBackButton = () => { - switch (transferStep) { - case TransactionSteps.Recipient: { - navigate(-1); - break; - } - case TransactionSteps.Amount: { - setTransferStep(TransactionSteps.Recipient); - break; - } - case TransactionSteps.Confirm: { - setTransferStep(TransactionSteps.Amount); - break; - } - - default: { - navigate(-1); - break; - } - } + const getBackButton = { + [TransactionSteps.Recipient]: () => ( + navigate(-1)} /> + ), + [TransactionSteps.Amount]: () => ( + setTransferStep(TransactionSteps.Recipient)} + /> + ), + [TransactionSteps.Confirm]: () => ( + setTransferStep(TransactionSteps.Amount)} + /> + ), + [TransactionSteps.ConfirmWithLedger]: () => ( + setTransferStep(TransactionSteps.Confirm)} + /> + ), + [TransactionSteps.Success]: undefined }; const transactionFee = isErc20Transfer ? `${paymentAmount}` : `${motesToCSPR(TRANSFER_COST_MOTES)}`; - return ( - ( - ( - - ) - } - /> - )} - renderContent={() => ( - - )} - renderFooter={() => ( + const renderFooter = () => { + if (transferStep === TransactionSteps.ConfirmWithLedger) { + return renderLedgerFooter({ + onConnect: makeSubmitLedgerAction, + event: ledgerEventStatusToRender, + onErrorCtaPressed: () => { + setTransferStep(TransactionSteps.Confirm); + } + }); + } + + return () => { + return ( {transferStep === TransactionSteps.Confirm || transferStep === TransactionSteps.Success ? null : ( @@ -409,13 +470,21 @@ export const TransferPage = () => { )} {transferStep === TransactionSteps.Success && !isRecipientPublicKeyInContact && ( @@ -433,7 +502,34 @@ export const TransferPage = () => { )} + ); + }; + }; + + return ( + ( + + )} + renderContent={() => ( + )} + renderFooter={renderFooter()} /> ); }; diff --git a/src/apps/popup/pages/transfer/utils.ts b/src/apps/popup/pages/transfer/utils.ts index e4c15a9ef..4a6653c12 100644 --- a/src/apps/popup/pages/transfer/utils.ts +++ b/src/apps/popup/pages/transfer/utils.ts @@ -2,6 +2,7 @@ export enum TransactionSteps { Recipient = 'recipient', Amount = 'amount', Confirm = 'confirm', + ConfirmWithLedger = 'confirm with ledger', Success = 'success' } diff --git a/src/apps/popup/router/paths.ts b/src/apps/popup/router/paths.ts index 05dc4197b..9558b75ad 100644 --- a/src/apps/popup/router/paths.ts +++ b/src/apps/popup/router/paths.ts @@ -31,5 +31,7 @@ export enum RouterPath { RateApp = '/rate-app', AllAccountsList = '/accounts-list', ImportAccountFromTorus = '/import-account-from-torus', - BuyCSPR = '/buy-cspr' + BuyCSPR = '/buy-cspr', + ImportAccountFromLedger = '/import-account-from-ledger', + SignWithLedgerInNewWindow = '/sign-with-ledger-in-new-window' } diff --git a/src/apps/signature-request/pages/sign-deploy/index.tsx b/src/apps/signature-request/pages/sign-deploy/index.tsx index 3aaa56cb3..74fd65ec9 100644 --- a/src/apps/signature-request/pages/sign-deploy/index.tsx +++ b/src/apps/signature-request/pages/sign-deploy/index.tsx @@ -5,6 +5,8 @@ import { useSelector } from 'react-redux'; import { getSigningAccount } from '@src/utils'; +import { RouterPath } from '@signature-request/router'; + import { closeCurrentWindow } from '@background/close-current-window'; import { selectConnectedAccountNamesWithActiveOrigin, @@ -13,16 +15,28 @@ import { } from '@background/redux/vault/selectors'; import { sendSdkResponseToSpecificTab } from '@background/send-sdk-response-to-specific-tab'; +import { useLedger } from '@hooks/use-ledger'; + import { sdkMethod } from '@content/sdk-method'; import { signDeploy } from '@libs/crypto'; import { convertBytesToHex } from '@libs/crypto/utils'; import { + AlignedFlexRow, FooterButtonsContainer, HeaderPopup, - LayoutWindow + HeaderSubmenuBarNavLink, + LayoutWindow, + SpacingSize } from '@libs/layout'; -import { Button } from '@libs/ui/components'; +import { LedgerEventStatus, ledger } from '@libs/services/ledger'; +import { HardwareWalletType } from '@libs/types/account'; +import { + Button, + LedgerEventView, + SvgIcon, + renderLedgerFooter +} from '@libs/ui/components'; import { CasperDeploy } from './deploy-types'; import { SignDeployContent } from './sign-deploy-content'; @@ -31,10 +45,18 @@ export function SignDeployPage() { const { t } = useTranslation(); const [deploy, setDeploy] = useState(undefined); + const [isSigningAccountFromLedger, setIsSigningAccountFromLedger] = + useState(false); const searchParams = new URLSearchParams(document.location.search); + const isLedgerNewWindow = Boolean(searchParams.get('initialEventToRender')); const requestId = searchParams.get('requestId'); const signingPublicKeyHex = searchParams.get('signingPublicKeyHex'); + const initialEventToRender = + (searchParams.get('initialEventToRender') as LedgerEventStatus) ?? + LedgerEventStatus.Disconnected; + const [showLedgerConfirm, setShowLedgerConfirm] = + useState(isLedgerNewWindow); if (!requestId || !signingPublicKeyHex) { throw Error('Missing search param'); @@ -55,7 +77,16 @@ export function SignDeployPage() { renderDeps ); + useEffect(() => { + const signingAccount = getSigningAccount(accounts, signingPublicKeyHex); + + setIsSigningAccountFromLedger( + signingAccount?.hardware === HardwareWalletType.Ledger + ); + }, [accounts, signingPublicKeyHex]); + const deployJsonById = useSelector(selectDeploysJsonById); + useEffect(() => { const deployJson = deployJsonById[requestId]; if (deployJson == null) { @@ -87,7 +118,8 @@ export function SignDeployPage() { // signing account should be connected to site if ( connectedAccountNames != null && - !connectedAccountNames.includes(signingAccount.name) + !connectedAccountNames.includes(signingAccount.name) && + !isLedgerNewWindow ) { const error = Error( 'Account with signingPublicKeyHex is not connected to site' @@ -96,16 +128,32 @@ export function SignDeployPage() { throw error; } - const handleSign = useCallback(() => { + const handleSign = useCallback(async () => { if (deploy?.hash == null) { return; } - const signature = signDeploy( - deploy.hash, - signingAccount.publicKey, - signingAccount.secretKey - ); + let signature: Uint8Array; + + if (signingAccount.hardware === HardwareWalletType.Ledger) { + const resp = await ledger.singDeploy(deploy, { + index: signingAccount.derivationIndex, + publicKey: signingAccount.publicKey + }); + + signature = resp.signature; + } else { + signature = signDeploy( + deploy.hash, + signingAccount.publicKey, + signingAccount.secretKey + ); + } + + if (!signature) { + return; + } + sendSdkResponseToSpecificTab( sdkMethod.signResponse( { signatureHex: convertBytesToHex(signature), cancelled: false }, @@ -114,9 +162,11 @@ export function SignDeployPage() { ); closeCurrentWindow(); }, [ - signingAccount?.publicKey, - signingAccount?.secretKey, - deploy?.hash, + deploy, + signingAccount.hardware, + signingAccount.derivationIndex, + signingAccount.publicKey, + signingAccount.secretKey, requestId ]); @@ -133,29 +183,91 @@ export function SignDeployPage() { return () => window.removeEventListener('beforeunload', handleCancel); }, [handleCancel]); + const { + ledgerEventStatusToRender, + makeSubmitLedgerAction, + closeNewLedgerWindowsAndClearState + } = useLedger({ + ledgerAction: handleSign, + beforeLedgerActionCb: async () => setShowLedgerConfirm(true), + initialEventToRender: { status: initialEventToRender }, + withWaitingEventOnDisconnect: false, + askPermissionUrlData: { + domain: 'signature-request.html', + params: { + requestId, + signingPublicKeyHex + }, + hash: RouterPath.SignDeploy + } + }); + + const onErrorCtaPressed = () => { + setShowLedgerConfirm(false); + closeNewLedgerWindowsAndClearState(); + }; + + const renderFooter = () => { + if (showLedgerConfirm) { + return renderLedgerFooter({ + onConnect: makeSubmitLedgerAction, + onErrorCtaPressed, + event: ledgerEventStatusToRender + }); + } + + return () => ( + + + + + ); + }; + return ( } - renderContent={() => ( - ( + ( + + ) + : undefined + } /> )} - renderFooter={() => ( - - - - - )} + renderContent={() => + showLedgerConfirm ? ( + + ) : ( + + ) + } + renderFooter={renderFooter()} /> ); } diff --git a/src/apps/signature-request/pages/sign-message/index.tsx b/src/apps/signature-request/pages/sign-message/index.tsx index 5b3c7d069..95c24be49 100644 --- a/src/apps/signature-request/pages/sign-message/index.tsx +++ b/src/apps/signature-request/pages/sign-message/index.tsx @@ -1,9 +1,11 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { getSigningAccount } from '@src/utils'; +import { RouterPath } from '@signature-request/router'; + import { closeCurrentWindow } from '@background/close-current-window'; import { selectConnectedAccountNamesWithActiveOrigin, @@ -11,26 +13,46 @@ import { } from '@background/redux/vault/selectors'; import { sendSdkResponseToSpecificTab } from '@background/send-sdk-response-to-specific-tab'; +import { useLedger } from '@hooks/use-ledger'; + import { sdkMethod } from '@content/sdk-method'; import { signMessage } from '@libs/crypto/sign-message'; import { convertBytesToHex } from '@libs/crypto/utils'; import { + AlignedFlexRow, FooterButtonsContainer, HeaderPopup, - LayoutWindow + HeaderSubmenuBarNavLink, + LayoutWindow, + SpacingSize } from '@libs/layout'; -import { Button } from '@libs/ui/components'; +import { LedgerEventStatus, ledger } from '@libs/services/ledger'; +import { HardwareWalletType } from '@libs/types/account'; +import { + Button, + LedgerEventView, + SvgIcon, + renderLedgerFooter +} from '@libs/ui/components'; import { SignMessageContent } from './sign-message-content'; export function SignMessagePage() { const { t } = useTranslation(); - const searchParams = new URLSearchParams(document.location.search); + const isLedgerNewWindow = Boolean(searchParams.get('initialEventToRender')); + const [isSigningAccountFromLedger, setIsSigningAccountFromLedger] = + useState(false); + const [showLedgerConfirm, setShowLedgerConfirm] = + useState(isLedgerNewWindow); + const requestId = searchParams.get('requestId'); const message = searchParams.get('message'); const signingPublicKeyHex = searchParams.get('signingPublicKeyHex'); + const initialEventToRender = + (searchParams.get('initialEventToRender') as LedgerEventStatus) ?? + LedgerEventStatus.Disconnected; if (!requestId || !message || !signingPublicKeyHex) { throw Error( @@ -53,6 +75,14 @@ export function SignMessagePage() { renderDeps ); + useEffect(() => { + const signingAccount = getSigningAccount(accounts, signingPublicKeyHex); + + setIsSigningAccountFromLedger( + signingAccount?.hardware === HardwareWalletType.Ledger + ); + }, [accounts, signingPublicKeyHex]); + const signingAccount = getSigningAccount(accounts, signingPublicKeyHex); // signing account should exist in wallet @@ -67,7 +97,8 @@ export function SignMessagePage() { // signing account should be connected to site if ( connectedAccountNames != null && - !connectedAccountNames.includes(signingAccount.name) + !connectedAccountNames.includes(signingAccount.name) && + !isLedgerNewWindow ) { const error = Error( 'Account with signingPublicKeyHex is not connected to site' @@ -78,16 +109,32 @@ export function SignMessagePage() { throw error; } - const handleSign = useCallback(() => { + const handleSign = useCallback(async () => { if (message == null) { return; } - const signature = signMessage( - message, - signingAccount.publicKey, - signingAccount.secretKey - ); + let signature: Uint8Array; + + if (signingAccount.hardware === HardwareWalletType.Ledger) { + const resp = await ledger.signMessage(message, { + index: signingAccount.derivationIndex, + publicKey: signingAccount.publicKey + }); + + signature = resp.signature; + } else { + signature = signMessage( + message, + signingAccount.publicKey, + signingAccount.secretKey + ); + } + + if (!signature) { + return; + } + sendSdkResponseToSpecificTab( sdkMethod.signMessageResponse( { signatureHex: convertBytesToHex(signature), cancelled: false }, @@ -96,12 +143,72 @@ export function SignMessagePage() { ); closeCurrentWindow(); }, [ - signingAccount?.publicKey, - signingAccount?.secretKey, message, + signingAccount.hardware, + signingAccount.derivationIndex, + signingAccount.publicKey, + signingAccount.secretKey, requestId ]); + const { + ledgerEventStatusToRender, + makeSubmitLedgerAction, + closeNewLedgerWindowsAndClearState + } = useLedger({ + ledgerAction: handleSign, + beforeLedgerActionCb: async () => setShowLedgerConfirm(true), + withWaitingEventOnDisconnect: false, + initialEventToRender: { status: initialEventToRender }, + askPermissionUrlData: { + domain: 'signature-request.html', + params: { + requestId, + signingPublicKeyHex, + message + }, + hash: RouterPath.SignMessage + } + }); + + const onErrorCtaPressed = () => { + setShowLedgerConfirm(false); + closeNewLedgerWindowsAndClearState(); + }; + + const renderFooter = () => { + if (showLedgerConfirm) { + return renderLedgerFooter({ + onConnect: makeSubmitLedgerAction, + onErrorCtaPressed, + event: ledgerEventStatusToRender + }); + } + + return () => ( + + + + + ); + }; + const handleCancel = useCallback(() => { sendSdkResponseToSpecificTab( sdkMethod.signResponse({ cancelled: true }, { requestId }) @@ -117,23 +224,31 @@ export function SignMessagePage() { return ( } - renderContent={() => ( - ( + ( + + ) + : undefined + } /> )} - renderFooter={() => ( - - - - - )} + renderContent={() => + showLedgerConfirm ? ( + + ) : ( + + ) + } + renderFooter={renderFooter()} /> ); } diff --git a/src/assets/icons/ledger-blue.svg b/src/assets/icons/ledger-blue.svg new file mode 100644 index 000000000..ab0f0a546 --- /dev/null +++ b/src/assets/icons/ledger-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/ledger-white.svg b/src/assets/icons/ledger-white.svg new file mode 100644 index 000000000..2ecd38bf9 --- /dev/null +++ b/src/assets/icons/ledger-white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/illustrations/ledger-connect.svg b/src/assets/illustrations/ledger-connect.svg new file mode 100644 index 000000000..9353c81b6 --- /dev/null +++ b/src/assets/illustrations/ledger-connect.svg @@ -0,0 +1,1211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/illustrations/ledger-error.svg b/src/assets/illustrations/ledger-error.svg new file mode 100644 index 000000000..205710823 --- /dev/null +++ b/src/assets/illustrations/ledger-error.svg @@ -0,0 +1,711 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/illustrations/ledger-not-connected.svg b/src/assets/illustrations/ledger-not-connected.svg new file mode 100644 index 000000000..0e2524eff --- /dev/null +++ b/src/assets/illustrations/ledger-not-connected.svg @@ -0,0 +1,1170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/illustrations/ledger-rejected.svg b/src/assets/illustrations/ledger-rejected.svg new file mode 100644 index 000000000..e2bdc1a2c --- /dev/null +++ b/src/assets/illustrations/ledger-rejected.svg @@ -0,0 +1,442 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/background/create-open-window.ts b/src/background/create-open-window.ts index b837d18c1..1d328b18e 100644 --- a/src/background/create-open-window.ts +++ b/src/background/create-open-window.ts @@ -149,3 +149,44 @@ export function createOpenWindow({ } }; } + +export interface IOpenNewSeparateWindowParams { + url: string; +} + +export async function openNewSeparateWindow({ + url +}: IOpenNewSeparateWindowParams): Promise { + const currentWindow = await windows.getCurrent(); + + // If this flag is true, we create a new window without any size and positions. + const isTestEnv = Boolean(process.env.TEST_ENV); + + const windowWidth = currentWindow.width ?? 0; + const xOffset = currentWindow.left ?? 0; + const yOffset = currentWindow.top ?? 0; + const crossPlatformWidthOffset = 16; + const popupWidth = 360 + crossPlatformWidthOffset; + const popupHeight = 800; + const newWindow = + // We need this check for Firefox. If the Firefox browser is in fullscreen mode it ignores the width and height that we set and opens a popup in a small size. + // So we check it and if it is in a fullscreen mode we didn't set width and height, and the popup will also open in fullscreen mode. + // This is a default behavior for Safari and Chrome, but Firefox doesn't do this, so we need to do this manually for it. + currentWindow.state === 'fullscreen' || isTestEnv + ? await windows.create({ + url, + type: 'normal', + focused: true + }) + : await windows.create({ + url, + type: 'normal', + height: popupHeight, + width: popupWidth, + left: windowWidth + xOffset - popupWidth, + top: yOffset, + focused: true + }); + + return newWindow; +} diff --git a/src/background/index.ts b/src/background/index.ts index 4aa191a0b..3c2fd6513 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -63,6 +63,12 @@ import { CheckAccountNameIsTakenAction, CheckSecretKeyExistAction } from '@background/redux/import-account-actions-should-be-removed'; +import { + ledgerDeployChanged, + ledgerNewWindowIdChanged, + ledgerRecipientToSaveOnSuccessChanged, + ledgerStateCleared +} from '@background/redux/ledger/actions'; import { askForReviewAfterChanged, ratedInStoreChanged @@ -73,6 +79,7 @@ import { accountImported, accountRemoved, accountRenamed, + accountsImported, activeAccountChanged, anotherAccountConnected, deployPayloadReceived, @@ -575,6 +582,7 @@ runtime.onMessage.addListener( case getType(vaultReseted): case getType(secretPhraseCreated): case getType(accountImported): + case getType(accountsImported): case getType(accountAdded): case getType(accountRemoved): case getType(accountRenamed): @@ -634,6 +642,10 @@ runtime.onMessage.addListener( case getType(askForReviewAfterChanged): case getType(accountBalancesChanged): case getType(accountBalancesReseted): + case getType(ledgerNewWindowIdChanged): + case getType(ledgerStateCleared): + case getType(ledgerDeployChanged): + case getType(ledgerRecipientToSaveOnSuccessChanged): store.dispatch(action); return sendResponse(undefined); diff --git a/src/background/open-onboarding-flow.ts b/src/background/open-onboarding-flow.ts index dba6dd538..19e38ab60 100644 --- a/src/background/open-onboarding-flow.ts +++ b/src/background/open-onboarding-flow.ts @@ -24,6 +24,7 @@ export async function enableOnboardingFlow() { export async function openOnboardingUi() { const { tabId, windowId } = await loadState(); let tabExist = false; + if (tabId != null && windowId != null) { try { const tab = await tabs.get(tabId); @@ -38,7 +39,19 @@ export async function openOnboardingUi() { } } - if (!tabExist) { + // this needed for case when the user goes to another url from onboarding + // and then click on Casper Wallet from the extension menu + const tab = + tabId != null && + (await tabs.get(tabId).catch(() => { + // catch error if the tab does not exist + })); + // check if the tab URL is the onboarding URL + const isOnboardingUrl = tab && tab.url?.includes('onboarding.html'); + + // create a tab if it does not exist or if it's not an onboarding URL + if (!tabExist || !isOnboardingUrl) { + console.log(123); tabs .create({ url: 'onboarding.html', active: true }) .then(tab => { diff --git a/src/background/redux/get-main-store.ts b/src/background/redux/get-main-store.ts index 5cd492c45..0bed04d0f 100644 --- a/src/background/redux/get-main-store.ts +++ b/src/background/redux/get-main-store.ts @@ -55,7 +55,8 @@ export const selectPopupState = (state: RootState): PopupState => { accountInfo: state.accountInfo, contacts: state.contacts, rateApp: state.rateApp, - accountBalances: state.accountBalances + accountBalances: state.accountBalances, + ledger: state.ledger }; }; diff --git a/src/background/redux/ledger/actions.ts b/src/background/redux/ledger/actions.ts new file mode 100644 index 000000000..734c537e4 --- /dev/null +++ b/src/background/redux/ledger/actions.ts @@ -0,0 +1,12 @@ +import { createAction } from 'typesafe-actions'; + +export const ledgerNewWindowIdChanged = createAction( + 'LEDGER_NEW_WINDOW_ID_CHANGED' +)(); +export const ledgerDeployChanged = createAction( + 'LEDGER_DEPLOY_CHANGED' +)(); +export const ledgerRecipientToSaveOnSuccessChanged = createAction( + 'LEDGER_RECIPIENT_TO_SAVE_ON_SUCCESS_CHANGED' +)(); +export const ledgerStateCleared = createAction('LEDGER_STATE_CLEARED')(); diff --git a/src/background/redux/ledger/reducer.ts b/src/background/redux/ledger/reducer.ts new file mode 100644 index 000000000..a5ebb6c25 --- /dev/null +++ b/src/background/redux/ledger/reducer.ts @@ -0,0 +1,42 @@ +import { createReducer } from 'typesafe-actions'; + +import { + ledgerDeployChanged, + ledgerNewWindowIdChanged, + ledgerRecipientToSaveOnSuccessChanged, + ledgerStateCleared +} from './actions'; +import { LedgerState } from './types'; + +type State = LedgerState; + +const initialState: State = { + windowId: null, + deploy: null, + recipientToSaveOnSuccess: null +}; + +export const reducer = createReducer(initialState) + .handleAction( + ledgerNewWindowIdChanged, + (state, { payload }): State => ({ + ...state, + windowId: payload + }) + ) + .handleAction(ledgerStateCleared, (): State => initialState) + .handleAction(ledgerDeployChanged, (state, { payload }): State => { + return { + ...state, + deploy: payload + }; + }) + .handleAction( + ledgerRecipientToSaveOnSuccessChanged, + (state, { payload }): State => { + return { + ...state, + recipientToSaveOnSuccess: payload + }; + } + ); diff --git a/src/background/redux/ledger/selectors.ts b/src/background/redux/ledger/selectors.ts new file mode 100644 index 000000000..127871f14 --- /dev/null +++ b/src/background/redux/ledger/selectors.ts @@ -0,0 +1,11 @@ +import { RootState } from 'typesafe-actions'; + +export const selectLedgerNewWindowId = (state: RootState): number | null => + state.ledger.windowId; + +export const selectLedgerDeploy = (state: RootState): string | null => + state.ledger.deploy; + +export const selectLedgerRecipientToSaveOnSuccess = ( + state: RootState +): string | null => state.ledger.recipientToSaveOnSuccess; diff --git a/src/background/redux/ledger/types.ts b/src/background/redux/ledger/types.ts new file mode 100644 index 000000000..daa1e8755 --- /dev/null +++ b/src/background/redux/ledger/types.ts @@ -0,0 +1,5 @@ +export interface LedgerState { + windowId: number | null; + deploy: string | null; + recipientToSaveOnSuccess: string | null; +} diff --git a/src/background/redux/redux-action.ts b/src/background/redux/redux-action.ts index 719449aa5..c9a854f30 100644 --- a/src/background/redux/redux-action.ts +++ b/src/background/redux/redux-action.ts @@ -6,6 +6,7 @@ import * as activeOrigin from './active-origin/actions'; import * as contacts from './contacts/actions'; import * as keys from './keys/actions'; import * as lastActivityTime from './last-activity-time/actions'; +import * as ledger from './ledger/actions'; import * as loginRetryCount from './login-retry-count/actions'; import * as loginRetryLockoutTime from './login-retry-lockout-time/actions'; import * as rateApp from './rate-app/actions'; @@ -33,7 +34,8 @@ const reduxAction = { accountInfo, contacts, rateApp, - accountBalances + accountBalances, + ledger }; export type ReduxAction = ActionType; diff --git a/src/background/redux/root-reducer.ts b/src/background/redux/root-reducer.ts index 32b6cf81d..a0bfdce60 100644 --- a/src/background/redux/root-reducer.ts +++ b/src/background/redux/root-reducer.ts @@ -6,6 +6,7 @@ import { reducer as activeOrigin } from './active-origin/reducer'; import { reducer as contacts } from './contacts/reducer'; import { reducer as keys } from './keys/reducer'; import { reducer as lastActivityTime } from './last-activity-time/reducer'; +import { reducer as ledger } from './ledger/reducer'; import { reducer as loginRetryCount } from './login-retry-count/reducer'; import { reducer as loginRetryLockoutTime } from './login-retry-lockout-time/reducer'; import { reducer as rateApp } from './rate-app/reducer'; @@ -31,7 +32,8 @@ const rootReducer = combineReducers({ accountInfo, contacts, rateApp, - accountBalances + accountBalances, + ledger }); export default rootReducer; diff --git a/src/background/redux/root-selector.ts b/src/background/redux/root-selector.ts index 580f082f7..6ba5c8ada 100644 --- a/src/background/redux/root-selector.ts +++ b/src/background/redux/root-selector.ts @@ -10,3 +10,4 @@ export * from './windowManagement/selectors'; export * from './settings/selectors'; export * from './recent-recipient-public-keys/selectors'; export * from './rate-app/selectors'; +export * from './ledger/selectors'; diff --git a/src/background/redux/sagas/vault-sagas.ts b/src/background/redux/sagas/vault-sagas.ts index 55ec224f8..1df56ac45 100644 --- a/src/background/redux/sagas/vault-sagas.ts +++ b/src/background/redux/sagas/vault-sagas.ts @@ -52,6 +52,7 @@ import { accountImported, accountRemoved, accountRenamed, + accountsImported, activeAccountChanged, anotherAccountConnected, deployPayloadReceived, @@ -96,6 +97,7 @@ export function* vaultSagas() { [ getType(accountAdded), getType(accountImported), + getType(accountsImported), getType(accountRemoved), getType(accountRenamed), getType(siteConnected), diff --git a/src/background/redux/types.d.ts b/src/background/redux/types.d.ts index 37eed11df..70b4636d5 100644 --- a/src/background/redux/types.d.ts +++ b/src/background/redux/types.d.ts @@ -6,6 +6,7 @@ import { ActiveOriginState } from '@background/redux/active-origin/types'; import { ContactsState } from '@background/redux/contacts/types'; import { KeysState } from '@background/redux/keys/types'; import { LastActivityTimeState } from '@background/redux/last-activity-time/reducer'; +import { LedgerState } from '@background/redux/ledger/types'; import { LoginRetryCountState } from '@background/redux/login-retry-count/reducer'; import { LoginRetryLockoutTimeState } from '@background/redux/login-retry-lockout-time/types'; import { RateAppState } from '@background/redux/rate-app/types'; @@ -49,4 +50,5 @@ export type PopupState = { contacts: ContactsState; rateApp: RateAppState; accountBalances: AccountBalancesState; + ledger: LedgerState; }; diff --git a/src/background/redux/vault/actions.ts b/src/background/redux/vault/actions.ts index 52a5bbf7b..44c68bb16 100644 --- a/src/background/redux/vault/actions.ts +++ b/src/background/redux/vault/actions.ts @@ -14,10 +14,15 @@ export const secretPhraseCreated = createAction( )(); export const accountImported = createAction('ACCOUNT_IMPORTED')(); + export const accountAdded = createAction('ACCOUNT_ADDED')(); + +export const accountsImported = createAction('ACCOUNTS_IMPORTED')(); + export const accountRemoved = createAction('ACCOUNT_REMOVED')<{ accountName: string; }>(); + export const accountRenamed = createAction('ACCOUNT_RENAMED')<{ oldName: string; newName: string; diff --git a/src/background/redux/vault/reducer.ts b/src/background/redux/vault/reducer.ts index 2fb9f2770..5c1d41954 100644 --- a/src/background/redux/vault/reducer.ts +++ b/src/background/redux/vault/reducer.ts @@ -6,6 +6,7 @@ import { accountImported, accountRemoved, accountRenamed, + accountsImported, activeAccountChanged, anotherAccountConnected, deployPayloadReceived, @@ -80,14 +81,21 @@ export const reducer = createReducer(initialState) ( state, { payload: account }: ReturnType - ): State => { - return { - ...state, - accounts: [...state.accounts, account], - activeAccountName: - state.accounts.length === 0 ? account.name : state.activeAccountName - }; - } + ): State => ({ + ...state, + accounts: [...state.accounts, account], + activeAccountName: + state.accounts.length === 0 ? account.name : state.activeAccountName + }) + ) + .handleAction( + accountsImported, + (state, { payload: accounts }: ReturnType) => ({ + ...state, + accounts: [...state.accounts, ...accounts], + activeAccountName: + state.accounts.length === 0 ? accounts[0].name : state.activeAccountName + }) ) .handleAction( accountRemoved, diff --git a/src/background/redux/vault/selectors.ts b/src/background/redux/vault/selectors.ts index 514cf8841..1d1caea14 100644 --- a/src/background/redux/vault/selectors.ts +++ b/src/background/redux/vault/selectors.ts @@ -5,7 +5,11 @@ import { selectAccountBalances } from '@background/redux/account-balances/select import { VaultState } from '@background/redux/vault/types'; import { SecretPhrase } from '@libs/crypto'; -import { Account, AccountWithBalance } from '@libs/types/account'; +import { + Account, + AccountWithBalance, + HardwareWalletType +} from '@libs/types/account'; import { selectActiveOrigin } from '../active-origin/selectors'; @@ -85,9 +89,25 @@ export const selectVaultHiddenAccountsNames = createSelector( accounts => accounts.map(account => account.name) ); +export const selectVaultHasImportedAccount = createSelector( + selectVaultImportedAccounts, + importedAccounts => importedAccounts.length > 0 +); + export const selectVaultDerivedAccounts = createSelector( selectVaultAccountsWithBalances, - accounts => accounts.filter(account => !account.imported) + accounts => accounts.filter(account => !account.imported && !account.hardware) +); + +export const selectVaultLedgerAccounts = createSelector( + selectVaultAccountsWithBalances, + accounts => + accounts.filter(account => account.hardware === HardwareWalletType.Ledger) +); + +export const selectVaultLedgerAccountNames = createSelector( + selectVaultLedgerAccounts, + accounts => accounts.map(account => account.name) ); export const selectVaultAccountsSecretKeysBase64 = createSelector( @@ -113,6 +133,11 @@ export const selectVaultActiveAccount = createSelector( } ); +export const selectIsActiveAccountFromLedger = createSelector( + selectVaultActiveAccount, + account => Boolean(account && account.hardware === HardwareWalletType.Ledger) +); + export const selectAccountNamesByOriginDict = (state: RootState) => state.vault.accountNamesByOriginDict; diff --git a/src/constants.ts b/src/constants.ts index b4e36d2c2..8047aae37 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -43,6 +43,9 @@ export const getContractNftUrl = ( tokenId: string ) => `${casperLiveUrl}/contracts/${contractHash}/nfts/${tokenId}`; +export const ledgerSupportLink = + 'https://support.ledger.com/hc/en-us/articles/4416379141009-Casper-CSPR?docs=true'; + export enum CasperLiveUrl { MainnetUrl = 'https://cspr.live', TestnetUrl = 'https://testnet.cspr.live' @@ -163,6 +166,7 @@ export enum StakeSteps { NewValidator = 'new validator', Amount = 'amount', Confirm = 'confirm', + ConfirmWithLedger = 'confirm with ledger', Success = 'success' } diff --git a/src/fixtures/initial-state-for-popup-tests.ts b/src/fixtures/initial-state-for-popup-tests.ts index be820cd4d..8da58cac4 100644 --- a/src/fixtures/initial-state-for-popup-tests.ts +++ b/src/fixtures/initial-state-for-popup-tests.ts @@ -73,6 +73,11 @@ export const initialStateForPopupTests: RootState = { windowManagement: { windowId: null }, + ledger: { + windowId: null, + deploy: null, + recipientToSaveOnSuccess: null + }, vaultCipher: 'G89IRk1Zc+l46uPzkhTwSy09IUM5Q4R1JoIfOCeyMZEn47OnFK7Rk1fSPJ9gsSVsiq+d00AqKuW/lTV+s1OTGOucftVqKBF6XSyR9tG7P2sgRyJ6o5vS/h+tVSyqHt6wHFuTcee1IResAfxPJEjiKbMMm7gN1eFosvqM8utdBOgIkR17+HiojfvdI0Q07kWZXy0SuUceSxnXGHZU2LdMikZI2JmkaEgk+Qgm/nNzqlN2hAKxQRhr+68opUiIN/lpOYPLS64nZou6vuqSKu+Uogd8znNZOcFA+4+1zXlbJEp8HksSqy+fblAxDALpauljIogoPfwLIaSPU1GSwTfG63yuCiMVlAE+FwOAt31J+m0N++obOTomfp6ZjN0uOG700Kfm5NSWMMXqCp/f/M8C466/ONqsl0og/R1KXOw0nPYybzmgXCyS35yZyOXmxzKrKtXRdYVTBz79pjMbR8p1CCDnVLHJyKKIGbsGrX3ADjwkJHmBEjGPL2Qb4Ez7ATzcQ/XEdcK+VfzbNkJivssPMBV+6ETNWrwPbIR4BxfN12TbmdAej7nbP+oaM1plKhcoW1hp0oD60Ngwh8D1ztD9i+3R9yDGVNwjh56ytvk5E1Fo7e02NYBJgjvHFoBz+fX4iHlliHczRRVC3OVceZcPPMCeVuigkz7wirqscxBfnrc+EBXrziOrEc4NobSKJI33UEZAMLjxLZSD8CR9J9RrJzFCrda44P65uSypiSyw49EPdsG4etW9Eop2iHNO5Ny7oCr7mITsFvFkGtXDh+tQ4r6D4b7ZGe2AD2Jm/4t9jcBsPO3wHxPfS7eIHq8RUJZUK7DL90s8gt0wXzIFgIeMIc+mcK0HigU+zYaBHO9O+PUfetEHZANmSwsRu3nmiHogEZaPJAT+ATY3+3GjNMQ=', loginRetryLockoutTime: null, diff --git a/src/hooks/use-is-dark-mode.ts b/src/hooks/use-is-dark-mode.ts new file mode 100644 index 000000000..ed54d420e --- /dev/null +++ b/src/hooks/use-is-dark-mode.ts @@ -0,0 +1,16 @@ +import { useSelector } from 'react-redux'; + +import { selectThemeModeSetting } from '@background/redux/settings/selectors'; +import { ThemeMode } from '@background/redux/settings/types'; + +import { useSystemThemeDetector } from '@hooks/use-system-theme-detector'; + +export const useIsDarkMode = () => { + const themeMode = useSelector(selectThemeModeSetting); + + const isSystemDarkTheme = useSystemThemeDetector(); + + return themeMode === ThemeMode.SYSTEM + ? isSystemDarkTheme + : themeMode === ThemeMode.DARK; +}; diff --git a/src/hooks/use-ledger.ts b/src/hooks/use-ledger.ts new file mode 100644 index 000000000..7a86bed71 --- /dev/null +++ b/src/hooks/use-ledger.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { windows } from 'webextension-polyfill'; + +import { RouterPath } from '@popup/router'; + +import { openNewSeparateWindow } from '@background/create-open-window'; +import { + ledgerNewWindowIdChanged, + ledgerStateCleared +} from '@background/redux/ledger/actions'; +import { selectLedgerNewWindowId } from '@background/redux/ledger/selectors'; +import { dispatchToMainStore } from '@background/redux/utils'; + +import { + ILedgerEvent, + IsBluetoothLedgerTransportAvailable, + LedgerEventStatus, + LedgerTransport, + SelectedTransport, + bluetoothTransportCreator, + getPreferredTransport, + isLedgerError, + isTransportAvailable, + ledger, + usbTransportCreator +} from '@libs/services/ledger'; + +interface IUseLedgerParams { + ledgerAction: () => Promise; + beforeLedgerActionCb: () => Promise; + initialEventToRender?: ILedgerEvent; + shouldLoadAccountList?: boolean; + withWaitingEventOnDisconnect?: boolean; + /** We have to open new browser window to handle device permission */ + askPermissionUrlData?: { + domain: string; + params?: Record; + hash: string; + }; +} + +export const useLedger = ({ + ledgerAction, + beforeLedgerActionCb, + initialEventToRender = { + status: LedgerEventStatus.WaitingResponseFromDevice + }, + withWaitingEventOnDisconnect = true, + shouldLoadAccountList = false, + askPermissionUrlData = { + domain: 'popup.html', + params: {}, + hash: RouterPath.SignWithLedgerInNewWindow + } +}: IUseLedgerParams) => { + const [isLedgerConnected, setIsLedgerConnected] = useState( + ledger.isConnected + ); + const [ledgerEventStatusToRender, setLedgerEventStatusToRender] = + useState(initialEventToRender); + const windowId = useSelector(selectLedgerNewWindowId); + const shouldTrySignAfterConnectRef = useRef(false); + const selectedTransportRef = useRef(undefined); + const isFirstEventRef = useRef(true); + const triggeredRef = useRef(false); + + const params = new URLSearchParams({ + ...(askPermissionUrlData.params ?? {}), + initialEventToRender: LedgerEventStatus.LedgerAskPermission, + ...(selectedTransportRef.current + ? { ledgerTransport: selectedTransportRef.current } + : {}) + }).toString(); + + const url = useMemo( + () => + `${askPermissionUrlData.domain}?${params}#${askPermissionUrlData.hash}`, + [askPermissionUrlData.domain, askPermissionUrlData.hash, params] + ); + + const makeSubmitLedgerAction = (transport?: LedgerTransport) => async () => { + if (!transport && !selectedTransportRef.current) { + selectedTransportRef.current = await getPreferredTransport(); + } + + if (transport) { + selectedTransportRef.current = transport; + } + + setLedgerEventStatusToRender({ + status: LedgerEventStatus.WaitingResponseFromDevice + }); + + await beforeLedgerActionCb(); + + if (isLedgerConnected) { + ledgerAction(); + + if (shouldLoadAccountList) { + setLedgerEventStatusToRender({ + status: LedgerEventStatus.LoadingAccountsList + }); + } + } else { + shouldTrySignAfterConnectRef.current = true; + + try { + if (selectedTransportRef.current === 'USB') { + await ledger.connect(usbTransportCreator, isTransportAvailable); + } else if (selectedTransportRef.current === 'Bluetooth') { + await ledger.connect( + bluetoothTransportCreator, + IsBluetoothLedgerTransportAvailable, + true + ); + } else { + setLedgerEventStatusToRender({ + status: LedgerEventStatus.Disconnected + }); + } + } catch (e) { + setIsLedgerConnected(false); + } + } + }; + + useEffect(() => { + const sub = ledger.subscribeToLedgerEventStatuss(event => { + if (event.status === LedgerEventStatus.Connected) { + setIsLedgerConnected(true); + } else if (event.status === LedgerEventStatus.Disconnected) { + setIsLedgerConnected(false); + + if (withWaitingEventOnDisconnect) { + setLedgerEventStatusToRender({ + status: LedgerEventStatus.WaitingResponseFromDevice + }); + } + } else if ( + event.status === LedgerEventStatus.SignatureRequestedToUser || + event.status === LedgerEventStatus.MsgSignatureRequestedToUser || + event.status === LedgerEventStatus.AccountListUpdated || + event.status === LedgerEventStatus.LoadingAccountsList || + event.status === LedgerEventStatus.WaitingResponseFromDevice || + isLedgerError(event) + ) { + setLedgerEventStatusToRender(event); + } + + if (isFirstEventRef.current && isLedgerError(event)) { + setLedgerEventStatusToRender({ + status: LedgerEventStatus.Disconnected + }); + setIsLedgerConnected(false); + } + + isFirstEventRef.current = false; + console.log('-------- event', JSON.stringify(event.status, null, ' ')); + }); + + return () => sub.unsubscribe(); + }, [withWaitingEventOnDisconnect]); + + useEffect(() => { + if (isLedgerConnected && shouldTrySignAfterConnectRef.current) { + makeSubmitLedgerAction(selectedTransportRef.current)(); + shouldTrySignAfterConnectRef.current = false; + } + }, [isLedgerConnected, makeSubmitLedgerAction]); + + /** We have to open new browser window to handle device permission */ + useEffect(() => { + (async () => { + if ( + ledgerEventStatusToRender.status === + LedgerEventStatus.LedgerPermissionRequired && + !windowId && + !triggeredRef.current + ) { + const w = await openNewSeparateWindow({ url }); + + if (w.id) { + dispatchToMainStore(ledgerNewWindowIdChanged(w.id)); + triggeredRef.current = true; + + const handleCloseWindow = () => { + dispatchToMainStore(ledgerStateCleared()); + windows.onRemoved.removeListener(handleCloseWindow); + }; + + windows.onRemoved.addListener(handleCloseWindow); + } + } + })(); + }, [ledgerEventStatusToRender.status, url, windowId]); + + const closeNewLedgerWindowsAndClearState = useCallback(async () => { + if (windowId) { + const all = await windows.getAll({ windowTypes: ['popup'] }); + all.forEach(w => w.id && windows.remove(w.id)); + dispatchToMainStore(ledgerStateCleared()); + await windows.remove(windowId); + } + }, [windowId]); + + useEffect(() => { + if (windowId && askPermissionUrlData?.domain !== 'popup.html') { + const sub = ledger.subscribeToLedgerEventStatuss(event => { + if ( + event.status === LedgerEventStatus.SignatureCompleted || + event.status === LedgerEventStatus.MsgSignatureCompleted + ) { + closeNewLedgerWindowsAndClearState(); + } + }); + + return () => sub.unsubscribe(); + } + }, [ + askPermissionUrlData?.domain, + closeNewLedgerWindowsAndClearState, + windowId + ]); + + return { + ledgerEventStatusToRender, + isLedgerConnected, + makeSubmitLedgerAction, + closeNewLedgerWindowsAndClearState, + windowId + }; +}; diff --git a/src/libs/animations/dots_dark_mode.json b/src/libs/animations/dots_dark_mode.json new file mode 100644 index 000000000..49cb26267 --- /dev/null +++ b/src/libs/animations/dots_dark_mode.json @@ -0,0 +1,284 @@ +{ + "nm": "Comp 1", + "ddd": 0, + "h": 1200, + "w": 1200, + "meta": { "g": "LottieFiles AE 0.1.20" }, + "layers": [ + { + "ty": 4, + "nm": "Shape Layer 2", + "sr": 1, + "st": 0, + "op": 600, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-208, -6, 0], "ix": 1 }, + "s": { "a": 0, "k": [60.714, 60.714, 100], "ix": 6 }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.2, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [460, 600, 0], + "t": 0, + "ti": [0, 0, 0], + "to": [0, -16.667, 0] + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.4, "y": 1 }, + "s": [460, 500, 0], + "t": 15, + "ti": [0, -16.667, 0], + "to": [0, 0, 0] + }, + { "s": [460, 600, 0], "t": 40 } + ], + "ix": 2 + }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100, "ix": 11 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0], "ix": 3 }, + "s": { "a": 0, "k": [112, 112], "ix": 2 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.5608, 0.651, 1], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 100, "ix": 5 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [-208, -6], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 1 + }, + { + "ty": 4, + "nm": "Shape Layer 1", + "sr": 1, + "st": 0, + "op": 600, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-208, -6, 0], "ix": 1 }, + "s": { "a": 0, "k": [60.714, 60.714, 100], "ix": 6 }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.2, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [600, 600, 0], + "t": 10, + "ti": [0, 0, 0], + "to": [0, -16.667, 0] + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.4, "y": 1 }, + "s": [600, 500, 0], + "t": 25, + "ti": [0, -16.667, 0], + "to": [0, 0, 0] + }, + { "s": [600, 600, 0], "t": 50 } + ], + "ix": 2 + }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100, "ix": 11 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0], "ix": 3 }, + "s": { "a": 0, "k": [112, 112], "ix": 2 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.5608, 0.651, 1], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 100, "ix": 5 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [-208, -6], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 2 + }, + { + "ty": 4, + "nm": "Shape Layer 3", + "sr": 1, + "st": 0, + "op": 600, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-208, -6, 0], "ix": 1 }, + "s": { "a": 0, "k": [60.714, 60.714, 100], "ix": 6 }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.2, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [740, 600, 0], + "t": 20, + "ti": [0, 0, 0], + "to": [0, -16.667, 0] + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.4, "y": 1 }, + "s": [740, 500, 0], + "t": 35, + "ti": [0, -16.667, 0], + "to": [0, 0, 0] + }, + { "s": [740, 600, 0], "t": 60 } + ], + "ix": 2 + }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100, "ix": 11 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0], "ix": 3 }, + "s": { "a": 0, "k": [112, 112], "ix": 2 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.5608, 0.651, 1], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 100, "ix": 5 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [-208, -6], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 3 + } + ], + "v": "5.5.7", + "fr": 60, + "op": 60, + "ip": 0, + "assets": [] +} diff --git a/src/libs/animations/dots_light_mode.json b/src/libs/animations/dots_light_mode.json new file mode 100644 index 000000000..259375d71 --- /dev/null +++ b/src/libs/animations/dots_light_mode.json @@ -0,0 +1,284 @@ +{ + "nm": "Comp 1", + "ddd": 0, + "h": 1200, + "w": 1200, + "meta": { "g": "LottieFiles AE 0.1.20" }, + "layers": [ + { + "ty": 4, + "nm": "Shape Layer 2", + "sr": 1, + "st": 0, + "op": 600, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-208, -6, 0], "ix": 1 }, + "s": { "a": 0, "k": [60.714, 60.714, 100], "ix": 6 }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.2, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [460, 600, 0], + "t": 0, + "ti": [0, 0, 0], + "to": [0, -16.667, 0] + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.4, "y": 1 }, + "s": [460, 500, 0], + "t": 15, + "ti": [0, -16.667, 0], + "to": [0, 0, 0] + }, + { "s": [460, 600, 0], "t": 40 } + ], + "ix": 2 + }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100, "ix": 11 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0], "ix": 3 }, + "s": { "a": 0, "k": [112, 112], "ix": 2 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0, 0.1294, 0.6471], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 100, "ix": 5 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [-208, -6], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 1 + }, + { + "ty": 4, + "nm": "Shape Layer 1", + "sr": 1, + "st": 0, + "op": 600, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-208, -6, 0], "ix": 1 }, + "s": { "a": 0, "k": [60.714, 60.714, 100], "ix": 6 }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.2, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [600, 600, 0], + "t": 10, + "ti": [0, 0, 0], + "to": [0, -16.667, 0] + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.4, "y": 1 }, + "s": [600, 500, 0], + "t": 25, + "ti": [0, -16.667, 0], + "to": [0, 0, 0] + }, + { "s": [600, 600, 0], "t": 50 } + ], + "ix": 2 + }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100, "ix": 11 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0], "ix": 3 }, + "s": { "a": 0, "k": [112, 112], "ix": 2 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0, 0.1294, 0.6471], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 100, "ix": 5 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [-208, -6], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 2 + }, + { + "ty": 4, + "nm": "Shape Layer 3", + "sr": 1, + "st": 0, + "op": 600, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [-208, -6, 0], "ix": 1 }, + "s": { "a": 0, "k": [60.714, 60.714, 100], "ix": 6 }, + "sk": { "a": 0, "k": 0 }, + "p": { + "a": 1, + "k": [ + { + "o": { "x": 0.2, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [740, 600, 0], + "t": 20, + "ti": [0, 0, 0], + "to": [0, -16.667, 0] + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.4, "y": 1 }, + "s": [740, 500, 0], + "t": 35, + "ti": [0, -16.667, 0], + "to": [0, 0, 0] + }, + { "s": [740, 600, 0], "t": 60 } + ], + "ix": 2 + }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100, "ix": 11 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, 0], "ix": 3 }, + "s": { "a": 0, "k": [112, 112], "ix": 2 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0, 0.1294, 0.6471], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 100, "ix": 5 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [-208, -6], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 3 + } + ], + "v": "5.5.7", + "fr": 60, + "op": 60, + "ip": 0, + "assets": [] +} diff --git a/src/libs/animations/spinner_dark_mode.json b/src/libs/animations/spinner_dark_mode.json new file mode 100644 index 000000000..91f39b831 --- /dev/null +++ b/src/libs/animations/spinner_dark_mode.json @@ -0,0 +1,491 @@ +{ + "nm": "Comp 1", + "ddd": 0, + "h": 500, + "w": 500, + "meta": { "g": "@lottiefiles/toolkit-js 0.26.1" }, + "layers": [ + { + "ty": 4, + "nm": "Shape Layer 5", + "sr": 1, + "st": 20, + "op": 620, + "ip": 20, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [251, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 20 + }, + { "s": [360], "t": 110 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { "a": 0, "k": [10, 10] } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.302, 0.4392, 1, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 1 + }, + { + "ty": 4, + "nm": "Shape Layer 4", + "sr": 1, + "st": 15, + "op": 615, + "ip": 15, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [251, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 15 + }, + { "s": [360], "t": 105 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { "a": 0, "k": [20, 20] } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.302, 0.4392, 1, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 2 + }, + { + "ty": 4, + "nm": "Shape Layer 3", + "sr": 1, + "st": 10, + "op": 610, + "ip": 10, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [251, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 10 + }, + { "s": [360], "t": 100 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { "a": 0, "k": [30, 30] } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.302, 0.4392, 1, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 3 + }, + { + "ty": 4, + "nm": "Shape Layer 2", + "sr": 1, + "st": 5, + "op": 605, + "ip": 5, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [251, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 5 + }, + { "s": [360], "t": 95 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { "a": 0, "k": [40, 40] } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.302, 0.4392, 1, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 4 + }, + { + "ty": 4, + "nm": "Shape Layer 1", + "sr": 1, + "st": 0, + "op": 600, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [250, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 0 + }, + { "s": [360], "t": 90 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [50, 50], + "t": 0 + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [40, 40], + "t": 84 + }, + { "s": [50, 50], "t": 100 } + ] + } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.302, 0.4392, 1, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 5 + } + ], + "v": "4.6.8", + "fr": 60, + "op": 106, + "ip": 0, + "assets": [] +} diff --git a/src/libs/animations/spinner_light_mode.json b/src/libs/animations/spinner_light_mode.json new file mode 100644 index 000000000..fcb3127ac --- /dev/null +++ b/src/libs/animations/spinner_light_mode.json @@ -0,0 +1,491 @@ +{ + "nm": "Comp 1", + "ddd": 0, + "h": 500, + "w": 500, + "meta": { "g": "@lottiefiles/toolkit-js 0.26.1" }, + "layers": [ + { + "ty": 4, + "nm": "Shape Layer 5", + "sr": 1, + "st": 20, + "op": 620, + "ip": 20, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [251, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 20 + }, + { "s": [360], "t": 110 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { "a": 0, "k": [10, 10] } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.0392, 0.1804, 0.749, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 1 + }, + { + "ty": 4, + "nm": "Shape Layer 4", + "sr": 1, + "st": 15, + "op": 615, + "ip": 15, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [251, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 15 + }, + { "s": [360], "t": 105 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { "a": 0, "k": [20, 20] } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.0392, 0.1804, 0.749, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 2 + }, + { + "ty": 4, + "nm": "Shape Layer 3", + "sr": 1, + "st": 10, + "op": 610, + "ip": 10, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [251, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 10 + }, + { "s": [360], "t": 100 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { "a": 0, "k": [30, 30] } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.0392, 0.1804, 0.749, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 3 + }, + { + "ty": 4, + "nm": "Shape Layer 2", + "sr": 1, + "st": 5, + "op": 605, + "ip": 5, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [251, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 5 + }, + { "s": [360], "t": 95 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { "a": 0, "k": [40, 40] } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.0392, 0.1804, 0.749, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 4 + }, + { + "ty": 4, + "nm": "Shape Layer 1", + "sr": 1, + "st": 0, + "op": 600, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "sk": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [250, 250, 0] }, + "r": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 0 + }, + { "s": [360], "t": 90 } + ] + }, + "sa": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ef": [], + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Ellipse 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "el", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Ellipse", + "nm": "Ellipse Path 1", + "d": 1, + "p": { "a": 0, "k": [0, -100] }, + "s": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [50, 50], + "t": 0 + }, + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [40, 40], + "t": 84 + }, + { "s": [50, 50], "t": 100 } + ] + } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0, 0, 0, 1] } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [0.0392, 0.1804, 0.749, 1] }, + "r": 1, + "o": { "a": 0, "k": 100 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "o": { "a": 0, "k": 100, "ix": 7 } + } + ] + } + ], + "ind": 5 + } + ], + "v": "4.6.8", + "fr": 60, + "op": 106, + "ip": 0, + "assets": [] +} diff --git a/src/libs/layout/header/header-submenu-bar-nav-link.tsx b/src/libs/layout/header/header-submenu-bar-nav-link.tsx index b54c5d614..8108ffeea 100644 --- a/src/libs/layout/header/header-submenu-bar-nav-link.tsx +++ b/src/libs/layout/header/header-submenu-bar-nav-link.tsx @@ -61,7 +61,9 @@ export function HeaderSubmenuBarNavLink({ return ( navigate(RouterPath.Home)} + onClick={() => + onClick != null ? onClick() : navigate(RouterPath.Home) + } /> ); diff --git a/src/libs/layout/layout-window.tsx b/src/libs/layout/layout-window.tsx index 8421705fd..c375ab1c1 100644 --- a/src/libs/layout/layout-window.tsx +++ b/src/libs/layout/layout-window.tsx @@ -32,6 +32,7 @@ const PageFooter = styled.footer``; const Container = styled(FlexColumn)` height: 100%; + width: 100%; `; export function LayoutWindow({ diff --git a/src/libs/layout/unlock-vault/index.tsx b/src/libs/layout/unlock-vault/index.tsx index 4d9eabde1..d9fc924e4 100644 --- a/src/libs/layout/unlock-vault/index.tsx +++ b/src/libs/layout/unlock-vault/index.tsx @@ -233,7 +233,7 @@ export function UnlockVaultPageContent() { {isLoading ? ( { } }; -export const makeAuctionManagerDeployAndSing = async ( +export const makeAuctionManagerDeploy = async ( contractEntryPoint: AuctionManagerEntryPoint, delegatorPublicKeyHex: string, validatorPublicKeyHex: string, @@ -70,8 +75,7 @@ export const makeAuctionManagerDeployAndSing = async ( amountMotes: string, networkName: NetworkName, auctionManagerContractHash: string, - nodeUrl: CasperNodeUrl, - keys: Keys.AsymmetricKey[] + nodeUrl: CasperNodeUrl ) => { const hash = decodeBase16(auctionManagerContractHash); @@ -111,21 +115,18 @@ export const makeAuctionManagerDeployAndSing = async ( const payment = DeployUtil.standardPayment(deployCost); - const deploy = DeployUtil.makeDeploy(deployParams, session, payment); - - return deploy.sign(keys); + return DeployUtil.makeDeploy(deployParams, session, payment); }; -export const makeNativeTransferDeployAndSign = async ( - senderPublicKeyHex: string, +export const makeNativeTransferDeploy = async ( + activeAccount: AccountWithBalance, recipientPublicKeyHex: string, amountMotes: string, networkName: NetworkName, nodeUrl: CasperNodeUrl, - keys: Keys.AsymmetricKey[], transferIdMemo?: string ) => { - const senderPublicKey = CLPublicKey.fromHex(senderPublicKeyHex); + const senderPublicKey = CLPublicKey.fromHex(activeAccount.publicKey); const recipientPublicKey = CLPublicKey.fromHex(recipientPublicKeyHex); const date = await getDateForDeploy(nodeUrl); @@ -149,12 +150,10 @@ export const makeNativeTransferDeployAndSign = async ( const payment = DeployUtil.standardPayment(TRANSFER_COST_MOTES); - const deploy = DeployUtil.makeDeploy(deployParams, session, payment); - - return deploy.sign(keys); + return DeployUtil.makeDeploy(deployParams, session, payment); }; -export const makeCep18TransferDeployAndSign = async ( +export const makeCep18TransferDeploy = async ( nodeUrl: CasperNodeUrl, networkName: NetworkName, tokenContractHash: string | undefined, @@ -163,8 +162,7 @@ export const makeCep18TransferDeployAndSign = async ( amount: string, erc20Decimals: number | null, paymentAmount: string, - activeAccount: Account, - keys: Keys.AsymmetricKey[] + activeAccount: Account ) => { const cep18 = new CEP18Client(nodeUrl, networkName); @@ -195,23 +193,20 @@ export const makeCep18TransferDeployAndSign = async ( date // https://github.com/casper-network/casper-node/issues/4152 ); - const deploy = DeployUtil.makeDeploy( + return DeployUtil.makeDeploy( deployParams, tempDeploy.session, tempDeploy.payment ); - - return deploy.sign(keys); }; -export const makeNFTDeployAndSign = async ( +export const makeNFTDeploy = async ( runtimeArgs: RuntimeArgs, paymentAmount: string, deploySender: CLPublicKey, networkName: NetworkName, contractPackageHash: string, - nodeUrl: CasperNodeUrl, - keys: Keys.AsymmetricKey[] + nodeUrl: CasperNodeUrl ) => { const hash = Uint8Array.from(Buffer.from(contractPackageHash, 'hex')); @@ -233,7 +228,35 @@ export const makeNFTDeployAndSign = async ( runtimeArgs ); const payment = DeployUtil.standardPayment(paymentAmount); - const deploy = DeployUtil.makeDeploy(deployParams, session, payment); + + return DeployUtil.makeDeploy(deployParams, session, payment); +}; + +export const signLedgerDeploy = async ( + deploy: DeployUtil.Deploy, + activeAccount: Account +) => { + const resp = await ledger.singDeploy(deploy, { + index: activeAccount.derivationIndex, + publicKey: activeAccount.publicKey + }); + + const approval = new DeployUtil.Approval(); + approval.signer = activeAccount.publicKey; + approval.signature = resp.signatureHex; + deploy.approvals.push(approval); + + return deploy; +}; + +export const signDeploy = ( + deploy: DeployUtil.Deploy, + keys: Keys.AsymmetricKey[], + activeAccount: Account +) => { + if (activeAccount?.hardware === HardwareWalletType.Ledger) { + return signLedgerDeploy(deploy, activeAccount); + } return deploy.sign(keys); }; diff --git a/src/libs/services/ledger/errors.ts b/src/libs/services/ledger/errors.ts new file mode 100644 index 000000000..1d6dc8ede --- /dev/null +++ b/src/libs/services/ledger/errors.ts @@ -0,0 +1,78 @@ +import { ILedgerEvent, LedgerEventStatus } from './types'; + +interface ILedgerErrorData { + title: string | null; + description: string | null; +} + +export const ledgerErrorsData: Record = { + [LedgerEventStatus.Timeout]: { + title: 'Connection timeout', + description: 'The connection time is up, try again' + }, + [LedgerEventStatus.InvalidIndex]: { + title: 'Invalid account index', + description: + 'Try to remove the current account from the extension and add it again' + }, + [LedgerEventStatus.ErrorOpeningDevice]: { + title: 'Unable to connect to the Ledger device', + description: 'Check the Ledger device connection and try again' + }, + [LedgerEventStatus.LedgerPermissionRequired]: { + title: 'Please provide permission to connect your Ledger device', + description: + 'This permission is needed for each new device when connecting to the browser.' + }, + [LedgerEventStatus.MsgSignatureFailed]: { + title: 'Error when signing a message', + description: null + }, + [LedgerEventStatus.MsgSignatureCanceled]: { + title: 'You rejected to sign the message', + description: null + }, + [LedgerEventStatus.SignatureFailed]: { + title: 'Error when signing a deploy', + description: null + }, + [LedgerEventStatus.SignatureCanceled]: { + title: 'You rejected to sign the deploy', + description: null + }, + [LedgerEventStatus.AccountListFailed]: { + title: 'Synchronization of accounts from the Ledger device failed', + description: null + }, + [LedgerEventStatus.CasperAppNotLoaded]: { + title: 'Casper app isn’t open on Ledger', + description: + 'Please make sure to open Casper app on your Ledger and try connecting again.' + }, + [LedgerEventStatus.DeviceLocked]: { + title: 'The Ledger device is locked', + description: 'Unlock the Ledger device connection and try again' + }, + [LedgerEventStatus.NotAvailable]: { + title: "Your browser doesn't support connection to Ledger", + description: + 'Consider switching to latest versions of Chrome or Edge browsers' + }, + [LedgerEventStatus.WaitingToSignPrevDeploy]: { + title: 'Your have pending signing action on your Ledger device', + description: 'Handle the previous signing action and then make a new one' + }, + 'ledger-ask-permission': { title: null, description: null }, + 'ledger-account-list-updated': { title: null, description: null }, + 'ledger-connected': { title: null, description: null }, + 'ledger-disconnected': { title: null, description: null }, + 'ledger-loading-accounts-list': { title: null, description: null }, + 'ledger-msg-signature-completed': { title: null, description: null }, + 'ledger-msg-signature-requested-to-user': { title: null, description: null }, + 'ledger-signature-completed': { title: null, description: null }, + 'ledger-signature-requested-to-user': { title: null, description: null }, + 'ledger-waiting-response-from-device': { title: null, description: null } +}; + +export const isLedgerError = (event: ILedgerEvent) => + Boolean(ledgerErrorsData[event.status].title); diff --git a/src/libs/services/ledger/index.ts b/src/libs/services/ledger/index.ts new file mode 100644 index 000000000..49e97d32f --- /dev/null +++ b/src/libs/services/ledger/index.ts @@ -0,0 +1,4 @@ +export * from './ledger'; +export * from './transport'; +export * from './types'; +export * from './errors'; diff --git a/src/libs/services/ledger/ledger.ts b/src/libs/services/ledger/ledger.ts new file mode 100644 index 000000000..da3c9e5c5 --- /dev/null +++ b/src/libs/services/ledger/ledger.ts @@ -0,0 +1,562 @@ +import Transport from '@ledgerhq/hw-transport'; +import { blake2b } from '@noble/hashes/blake2b'; +import LedgerCasperApp from '@zondax/ledger-casper'; +import { ResponseSign } from '@zondax/ledger-casper/src/types'; +import { Buffer } from 'buffer'; +import { DeployUtil } from 'casper-js-sdk'; +import { + BehaviorSubject, + Observable, + Observer, + debounceTime, + distinct +} from 'rxjs'; + +import { getBip44Path } from '@libs/crypto'; + +import { + ILedgerEvent, + LedgerAccount, + LedgerAccountsOptions, + LedgerEventStatus, + SignResult +} from './types'; + +const CONNECTION_TIMEOUT_MS = 60000; +const CONNECTION_POLL_INTERVAL = 3000; + +export class LedgerError extends Error { + constructor(LedgerEventStatus: ILedgerEvent) { + super(JSON.stringify(LedgerEventStatus)); + } +} + +export class Ledger { + cachedAccounts: LedgerAccount[] = []; + + #transport: Transport | null = null; + #isBluetoothTransport: boolean = false; + #ledgerApp: LedgerCasperApp | null = null; + #ledgerConnected = false; + #allowReconnect: boolean = true; + #LedgerEventStatussSubject = new BehaviorSubject({ + status: LedgerEventStatus.Disconnected + }); + + subscribeToLedgerEventStatuss = (onData: (evt: ILedgerEvent) => void) => + this.#LedgerEventStatussSubject.pipe(debounceTime(300)).subscribe(onData); + + /** @throws {LedgerError} */ + async connect( + transportCreator: () => Promise, + checkTransportAvailability: () => Promise, + isBluetoothTransport = false + ): Promise { + this.#isBluetoothTransport = isBluetoothTransport; + + return new Promise(async (resolve, reject) => { + const available = await checkTransportAvailability(); + + if (!available) { + const evt = { status: LedgerEventStatus.NotAvailable }; + this.#LedgerEventStatussSubject.next(evt); + reject(new LedgerError(evt)); + } + + const connectionObserver: Observer = { + next: data => { + this.#LedgerEventStatussSubject.next(data); + + if ( + data.status === LedgerEventStatus.Timeout || + data.status === LedgerEventStatus.ErrorOpeningDevice + ) { + reject(new LedgerError(data)); + } + }, + error: () => { + const evt: ILedgerEvent = { + status: LedgerEventStatus.ErrorOpeningDevice + }; + this.#LedgerEventStatussSubject.next(evt); + reject(new LedgerError(evt)); + }, + complete: async () => { + resolve(); + } + }; + + try { + this.#transport = await transportCreator(); + this.#transport?.on('disconnect', this.#onDisconnect); + this.#ledgerApp = new LedgerCasperApp(this.#transport); + } catch (e) { + if (!this.#transport) { + const evt = { status: LedgerEventStatus.LedgerPermissionRequired }; + this.#LedgerEventStatussSubject.next(evt); + reject(new LedgerError(evt)); + return; + } + } + + this.#connectToLedger(transportCreator, connectionObserver); + }); + } + + async disconnect(): Promise { + if (this.#ledgerConnected) { + try { + await this.#transport?.close(); + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.Disconnected + }); + } catch (err: any) { + console?.error( + 'Error disconnecting from ledger: ' + err.name + ' - ' + err.message + ); + } + + this.#ledgerConnected = false; + } + + this.cachedAccounts = []; + + return true; + } + + get isConnected(): boolean { + return this.#ledgerConnected; + } + + async checkAppInfo(): Promise { + if (this.#ledgerConnected && this.#ledgerApp) { + const appInfo = await this.#ledgerApp?.getAppInfo(); + + await this.#processDelayAfterAction(); + + if (appInfo.returnCode === 65535) { + return LedgerEventStatus.WaitingToSignPrevDeploy; + } + + return appInfo.returnCode === 0x9000 && appInfo.appName === 'Casper' + ? null + : LedgerEventStatus.WaitingResponseFromDevice; + } + + return LedgerEventStatus.WaitingResponseFromDevice; + } + + /** @throws {LedgerError} message - ILedgerEvent JSON */ + getAccountList = async ({ + size, + offset + }: LedgerAccountsOptions): Promise => { + try { + if (!this.#ledgerApp || !this.#ledgerConnected) return; + + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.LoadingAccountsList + }); + + const response = await this.#ledgerApp.getAddressAndPubKey( + this.#getAccountPath(offset) + ); + await this.#processDelayAfterAction(); + + if (!response || response.returnCode !== 0x9000) { + if (response?.returnCode === 0xffff || response.returnCode === 21781) { + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.DeviceLocked + }); + } else if (response?.returnCode === 0x6e01) { + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.CasperAppNotLoaded + }); + } else { + this.#processError({ status: LedgerEventStatus.AccountListFailed }); + } + } + + const publicKeys: string[] = [this.#encodePublicKey(response.publicKey)]; + + for (let i = 1; i < size; i++) { + const key = await this.#ledgerApp.getAddressAndPubKey( + this.#getAccountPath(offset + i) + ); + await this.#processDelayAfterAction(); + + publicKeys.push(this.#encodePublicKey(key.publicKey)); + } + + const updatedAccountList = publicKeys.map((pk, i) => ({ + publicKey: pk, + index: offset + i + })); + + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.AccountListUpdated, + firstAcctIndex: offset, + accounts: updatedAccountList + }); + + if (offset === this.cachedAccounts.length) { + this.cachedAccounts.push(...updatedAccountList); + } + } catch (e) { + if (e instanceof LedgerError) { + throw e; + } else { + this.#processError({ status: LedgerEventStatus.AccountListFailed }); + } + } + }; + + /** @throws {LedgerError} message - ILedgerEvent JSON */ + async singDeploy( + deploy: DeployUtil.Deploy, + account: Partial + ): Promise { + try { + if (account.index === undefined) { + this.#processError({ status: LedgerEventStatus.InvalidIndex }); + } + + await this.#checkConnection(account.index); + + const deployHash = Buffer.from(deploy.hash).toString('hex'); + + const devicePk = + account.index !== undefined + ? await this.#ledgerApp?.getAddressAndPubKey( + this.#getAccountPath(account.index) + ) + : undefined; + await this.#processDelayAfterAction(); + + if (!devicePk) { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + error: 'Could not retrieve key by index from device', + publicKey: account.publicKey, + deployHash + }); + } + + const keyFromDevice: string = this.#encodePublicKey(devicePk.publicKey); + + if (account.publicKey !== keyFromDevice) { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + error: + 'Signing key not found on Ledger device. Signature process failed', + publicKey: account.publicKey, + deployHash + }); + } + + const deployBytes = DeployUtil.deployToBytes(deploy); + + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.SignatureRequestedToUser, + publicKey: account.publicKey, + deployHash: deployHash + }); + + let result: ResponseSign; + + if (deploy.session.isModuleBytes()) { + result = await this.#ledgerApp?.signWasmDeploy( + this.#getAccountPath(account.index), + Buffer.from(deployBytes) + ); + } else { + result = await this.#ledgerApp?.sign( + this.#getAccountPath(account.index), + Buffer.from(deployBytes) + ); + } + + await this.#processDelayAfterAction(); + + if (result.returnCode === 0x6986) { + //transaction rejected + this.#processError({ + status: LedgerEventStatus.SignatureCanceled, + publicKey: account.publicKey, + deployHash + }); + } + + if (result.returnCode !== 0x9000) { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + error: result.errorMessage, + publicKey: account.publicKey, + deployHash + }); + } + + // remove V byte if included + const patchedSignature = + result.signatureRSV.length > 64 + ? result.signatureRSV.subarray(0, 64) + : result.signatureRSV; + + const signatureHex = `02${patchedSignature.toString('hex')}`; + + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.SignatureCompleted, + publicKey: account.publicKey, + deployHash, + signatureHex + }); + + const prefix = new Uint8Array([0x02]); + + if (!signatureHex) { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + publicKey: account.publicKey, + deployHash, + error: `Empty signature` + }); + } + + return { + signatureHex, + signature: new Uint8Array([...prefix, ...patchedSignature]) + }; + } catch (e) { + if (e instanceof LedgerError) { + throw e; + } else { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + error: 'Unknown signature error' + }); + } + } + } + + /** @throws {LedgerError} message - ILedgerEvent JSON */ + async signMessage( + message: string, + account: Partial + ): Promise { + try { + if (account.index === undefined) { + this.#processError({ status: LedgerEventStatus.InvalidIndex }); + } + + await this.#checkConnection(account.index); + + const prefixedMessage = Buffer.from( + `Casper Message:\n${message}`, + 'utf-8' + ); + const hashedMessage = Buffer.from( + blake2b(prefixedMessage, { dkLen: 32 }) + ).toString('hex'); + + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.MsgSignatureRequestedToUser, + publicKey: account.publicKey, + message, + msgHash: hashedMessage + }); + + this.#transport?.setExchangeTimeout(10000); + + const result: ResponseSign = await this.#ledgerApp?.signMessage( + this.#getAccountPath(account.index), + prefixedMessage + ); + + await this.#processDelayAfterAction(); + + if (result.returnCode === 0x6986) { + //transaction rejected + this.#processError({ status: LedgerEventStatus.MsgSignatureCanceled }); + } + + if (result.returnCode !== 0x9000) { + this.#processError({ + status: LedgerEventStatus.MsgSignatureFailed, + error: `Error: ${result.errorMessage}` + }); + } + + // remove V byte if included + const patchedSignature = + result.signatureRSV.length > 64 + ? result.signatureRSV.subarray(0, 64) + : result.signatureRSV; + + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.MsgSignatureCompleted, + publicKey: account.publicKey, + message: message, + msgHash: hashedMessage, + signatureHex: patchedSignature.toString('hex') + }); + + return { + signatureHex: patchedSignature.toString('hex'), + signature: new Uint8Array(patchedSignature) + }; + } catch (e) { + if (e instanceof LedgerError) { + throw e; + } else { + this.#processError({ + status: LedgerEventStatus.MsgSignatureFailed, + error: 'Unknown msg signature error' + }); + } + } + } + + #checkConnection = async (accountIndex?: number) => { + let evt; + + if (Number.isNaN(Number(accountIndex))) { + evt = { status: LedgerEventStatus.InvalidIndex }; + } else if (!this.#ledgerConnected) { + evt = { status: LedgerEventStatus.CasperAppNotLoaded }; + } else { + const status = await this.checkAppInfo(); + + if (status) { + evt = { status }; + } + } + + if (evt) { + this.#processError(evt); + } + }; + + #onDisconnect = (evt: any) => { + console.log('device disconnected.', evt); + this.#ledgerConnected = false; + this.#allowReconnect = false; + this.cachedAccounts = []; + this.#LedgerEventStatussSubject.next({ + status: LedgerEventStatus.Disconnected + }); + this.#transport?.off('disconnect', this.#onDisconnect); + this.#transport = null; + + setTimeout(() => { + this.#allowReconnect = true; + }, CONNECTION_POLL_INTERVAL * 1.2); + }; + + #getAccountPath = (acctIdx: number): string => getBip44Path(acctIdx); + + #encodePublicKey = (bytes: Uint8Array) => + '02' + Buffer.from(bytes).toString('hex'); + + #connectToLedger( + transportCreator: () => Promise, + observer: Observer + ): void { + const observable = new Observable(subscriber => { + /** @return {boolean} is should stop retries */ + const retryConnection = async (): Promise => { + if (!this.#transport) { + try { + this.#transport = await transportCreator(); + this.#transport.on('disconnect', this.#onDisconnect); + this.#ledgerApp = new LedgerCasperApp(this.#transport); + } catch (error: any) { + console.log('Error connecting to a Ledger device', error); + subscriber.next({ status: LedgerEventStatus.ErrorOpeningDevice }); + + return true; + } + } + + if (!this.#transport || !this.#ledgerApp) { + console.debug('Cannot connect to device. Transport error'); + return false; + } + + subscriber.next({ + status: LedgerEventStatus.WaitingResponseFromDevice + }); + + try { + const appInfo = await this.#ledgerApp.getAppInfo(); + await this.#processDelayAfterAction(); + + if (appInfo.returnCode === 0xffff || appInfo.returnCode === 21781) { + subscriber.next({ status: LedgerEventStatus.DeviceLocked }); + + return false; + } + + if (appInfo.returnCode !== 0x9000) { + // subscriber.next({ status: LedgerEventStatus.DeviceLocked }); + return false; + } + + if (appInfo.appName !== 'Casper') { + subscriber.next({ status: LedgerEventStatus.CasperAppNotLoaded }); + + return false; + } + + this.#ledgerConnected = true; + subscriber.next({ status: LedgerEventStatus.Connected }); + + return true; + } catch (err) { + console.error('-------- err', err); + } + + return false; + }; + + retryConnection().then(async shouldStopRetries => { + if (shouldStopRetries) { + subscriber.complete(); + } else { + let timeoutLoops = CONNECTION_TIMEOUT_MS / CONNECTION_POLL_INTERVAL; + + const timer = setInterval(async () => { + if (--timeoutLoops <= 0) { + clearInterval(timer); + subscriber.next({ status: LedgerEventStatus.Timeout }); + } else if (!this.#allowReconnect) { + console.debug('waiting before for a reconnection attempt'); + return; + } else if (await retryConnection()) { + clearInterval(timer); + subscriber.complete(); + } + }, CONNECTION_POLL_INTERVAL); + } + }); + }); + + observable.pipe(distinct(({ status }) => status)).subscribe(observer); + } + + /** @throws {LedgerError} message - ILedgerEvent JSON */ + #processError(evt: ILedgerEvent): never { + this.#LedgerEventStatussSubject.next(evt); + throw new LedgerError(evt); + } + + /** Even though the Promise is resolved, bluetooth has not had time to process the messages + * We need to wait a bit due to errors + * */ + async #processDelayAfterAction() { + if (this.#isBluetoothTransport) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + } +} + +export const ledger = new Ledger(); diff --git a/src/libs/services/ledger/transport.ts b/src/libs/services/ledger/transport.ts new file mode 100644 index 000000000..e46d81831 --- /dev/null +++ b/src/libs/services/ledger/transport.ts @@ -0,0 +1,96 @@ +import { ledgerUSBVendorId } from '@ledgerhq/devices'; +import Transport from '@ledgerhq/hw-transport'; +import BluetoothTransport from '@ledgerhq/hw-transport-web-ble'; +import TransportWebHID from '@ledgerhq/hw-transport-webhid'; +import TransportWebUsb from '@ledgerhq/hw-transport-webusb'; +import { getLedgerDevices } from '@ledgerhq/hw-transport-webusb/lib/webusb'; + +import { LedgerError } from '@libs/services/ledger/ledger'; + +import { LedgerEventStatus, SelectedTransport } from './types'; + +export const IsUsbLedgerTransportAvailable = async (): Promise => { + const hidAvailable = await TransportWebHID.isSupported(); + + if (hidAvailable) { + return true; + } + + return await TransportWebUsb.isSupported(); +}; + +export const IsBluetoothLedgerTransportAvailable = async (): Promise => + BluetoothTransport.isSupported(); + +export const subscribeToBluetoothAvailability = + BluetoothTransport.observeAvailability; + +export const isTransportAvailable = async () => { + try { + return ( + await Promise.all([ + IsUsbLedgerTransportAvailable(), + IsBluetoothLedgerTransportAvailable() + ]) + ).some(Boolean); + } catch { + return false; + } +}; + +export const usbTransportCreator = async (): Promise => { + if (await TransportWebHID.isSupported()) { + const connected = await TransportWebHID.openConnected(); + + return connected || (await TransportWebHID.request()); + } else if (await TransportWebUsb.isSupported()) { + const connected = await TransportWebUsb.openConnected(); + + if (!connected) { + throw new LedgerError({ + status: LedgerEventStatus.LedgerPermissionRequired + }); + } + + return connected || (await TransportWebUsb.request()); + } else { + throw new Error('Usb connection not supported'); + } +}; + +export const bluetoothTransportCreator = async () => + BluetoothTransport.create(); + +export const getPreferredTransport = async (): Promise => { + if (await TransportWebHID.isSupported()) { + // Copy from TransportWebHID.getLedgerDevices source code + const getHID = (): null | Record<'getDevices', () => Promise> => { + // @ts-ignore + const { hid } = navigator; + + if (!hid) return null; + + return hid; + }; + + async function getHiDLedgerDevices(): Promise { + const devices = (await getHID()?.getDevices()) ?? []; + + return devices.filter((d: any) => d.vendorId === ledgerUSBVendorId); + } + + const devices = await getHiDLedgerDevices(); + + if (devices.length) { + return 'USB'; + } + } else if (await TransportWebUsb.isSupported()) { + const devices = await getLedgerDevices(); + + if (devices.length) { + return 'USB'; + } + } + + return undefined; +}; diff --git a/src/libs/services/ledger/types.ts b/src/libs/services/ledger/types.ts new file mode 100644 index 000000000..83ea3a913 --- /dev/null +++ b/src/libs/services/ledger/types.ts @@ -0,0 +1,55 @@ +export enum LedgerEventStatus { + Disconnected = 'ledger-disconnected', + NotAvailable = 'ledger-not-available', + DeviceLocked = 'ledger-device-locked', + WaitingResponseFromDevice = 'ledger-waiting-response-from-device', + WaitingToSignPrevDeploy = 'waiting-to-sign-prev-deploy', + CasperAppNotLoaded = 'ledger-casper-app-not-loaded', + Connected = 'ledger-connected', + LoadingAccountsList = 'ledger-loading-accounts-list', + AccountListUpdated = 'ledger-account-list-updated', + AccountListFailed = 'ledger-account-list-failed', + SignatureRequestedToUser = 'ledger-signature-requested-to-user', + SignatureCompleted = 'ledger-signature-completed', + SignatureCanceled = 'ledger-signature-cancelled', + SignatureFailed = 'ledger-signature-failed', + MsgSignatureRequestedToUser = 'ledger-msg-signature-requested-to-user', + MsgSignatureCompleted = 'ledger-msg-signature-completed', + MsgSignatureCanceled = 'ledger-msg-signature-cancelled', + MsgSignatureFailed = 'ledger-msg-signature-failed', + LedgerPermissionRequired = 'ledger-permission-required', + LedgerAskPermission = 'ledger-ask-permission', + ErrorOpeningDevice = 'ledger-error-opening-device', + Timeout = 'ledger-timeout', + InvalidIndex = 'ledger-invalid-index' +} + +export interface LedgerAccount { + publicKey: string; + index: number; +} + +export interface ILedgerEvent { + status: LedgerEventStatus; + publicKey?: string; + firstAcctIndex?: number; + accounts?: LedgerAccount[]; + deployHash?: string; + error?: string; + message?: string; + msgHash?: string; + signatureHex?: string; +} + +export interface LedgerAccountsOptions { + size: number; + offset: number; +} + +export interface SignResult { + signatureHex: string; + signature: Uint8Array; +} + +export type LedgerTransport = 'USB' | 'Bluetooth'; +export type SelectedTransport = LedgerTransport | undefined; diff --git a/src/libs/types/account.ts b/src/libs/types/account.ts index 70c9c1f9a..93cc29517 100644 --- a/src/libs/types/account.ts +++ b/src/libs/types/account.ts @@ -1,11 +1,17 @@ export interface KeyPair { - secretKey: string; + secretKey: string; // can be empty string publicKey: string; } export interface Account extends KeyPair { name: string; imported?: boolean; + hardware?: HardwareWalletType; hidden: boolean; + derivationIndex?: number; +} + +export enum HardwareWalletType { + Ledger = 'Ledger' } export interface AccountWithBalance extends Account { diff --git a/src/libs/ui/components/account-list/account-list-item.tsx b/src/libs/ui/components/account-list/account-list-item.tsx index e3cf14b06..b08bab155 100644 --- a/src/libs/ui/components/account-list/account-list-item.tsx +++ b/src/libs/ui/components/account-list/account-list-item.tsx @@ -6,7 +6,7 @@ import { FlexColumn, SpacingSize } from '@libs/layout'; -import { AccountListRows } from '@libs/types/account'; +import { AccountListRows, HardwareWalletType } from '@libs/types/account'; import { AccountActionsMenuPopover, Avatar, @@ -105,7 +105,8 @@ export const AccountListItem = ({ variant={HashVariant.CaptionHash} truncated withoutTooltip - withTag={account.imported} + isImported={account.imported} + isLedger={account.hardware === HardwareWalletType.Ledger} /> CSPR diff --git a/src/libs/ui/components/account-list/account-list.tsx b/src/libs/ui/components/account-list/account-list.tsx index 7566c47aa..f38f213b9 100644 --- a/src/libs/ui/components/account-list/account-list.tsx +++ b/src/libs/ui/components/account-list/account-list.tsx @@ -3,8 +3,10 @@ import { Trans, useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import styled from 'styled-components'; +import { isLedgerAvailable } from '@src/utils'; + import { useAccountManager } from '@popup/hooks/use-account-actions-with-events'; -import { RouterPath, useTypedNavigate } from '@popup/router'; +import { RouterPath, useTypedLocation, useTypedNavigate } from '@popup/router'; import { WindowApp } from '@background/create-open-window'; import { @@ -16,14 +18,14 @@ import { import { useWindowManager } from '@hooks/use-window-manager'; import { getAccountHashFromPublicKey } from '@libs/entities/Account'; -import { CenteredFlexRow, SpacingSize } from '@libs/layout'; +import { FlexColumn, SpacingSize } from '@libs/layout'; import { AccountListRows } from '@libs/types/account'; import { Button, List } from '@libs/ui/components'; import { sortAccounts } from '@libs/ui/components/account-list/utils'; import { AccountListItem } from './account-list-item'; -const ButtonContainer = styled(CenteredFlexRow)` +const ButtonContainer = styled(FlexColumn)` padding: 16px; `; @@ -32,8 +34,8 @@ interface AccountListProps { } export const AccountList = ({ closeModal }: AccountListProps) => { + const { pathname } = useTypedLocation(); const [accountListRows, setAccountListRows] = useState([]); - const { changeActiveAccountWithEvent: changeActiveAccount } = useAccountManager(); const { t } = useTranslation(); @@ -66,7 +68,7 @@ export const AccountList = ({ closeModal }: AccountListProps) => { { const isConnected = connectedAccountNames.includes(account.name); const isActiveAccount = activeAccountName === account.name; @@ -89,25 +91,37 @@ export const AccountList = ({ closeModal }: AccountListProps) => { + {isLedgerAvailable && ( + + )} )} /> diff --git a/src/libs/ui/components/avatar/avatar.tsx b/src/libs/ui/components/avatar/avatar.tsx index da5ac598e..c4d851db6 100644 --- a/src/libs/ui/components/avatar/avatar.tsx +++ b/src/libs/ui/components/avatar/avatar.tsx @@ -1,13 +1,9 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import styled, { DefaultTheme, useTheme } from 'styled-components'; import { isValidAccountHash, isValidPublicKey } from '@src/utils'; -import { selectThemeModeSetting } from '@background/redux/settings/selectors'; -import { ThemeMode } from '@background/redux/settings/types'; - -import { useSystemThemeDetector } from '@hooks/use-system-theme-detector'; +import { useIsDarkMode } from '@hooks/use-is-dark-mode'; import { AlignedFlexRow, @@ -59,14 +55,7 @@ export const Avatar = ({ }: AvatarTypes) => { const theme = useTheme(); - const themeMode = useSelector(selectThemeModeSetting); - - const isSystemDarkTheme = useSystemThemeDetector(); - - const isDarkMode = - themeMode === ThemeMode.SYSTEM - ? isSystemDarkTheme - : themeMode === ThemeMode.DARK; + const isDarkMode = useIsDarkMode(); const connectIcon = isDarkMode ? displayContext === 'header' diff --git a/src/libs/ui/components/checkbox/checkbox.tsx b/src/libs/ui/components/checkbox/checkbox.tsx index e6d20a501..43d224074 100644 --- a/src/libs/ui/components/checkbox/checkbox.tsx +++ b/src/libs/ui/components/checkbox/checkbox.tsx @@ -68,7 +68,10 @@ export function Checkbox({ data-testid={dataTestId} disabled={disabled} > - + {label && ( {label} @@ -77,5 +80,3 @@ export function Checkbox({ ); } - -export default Checkbox; diff --git a/src/libs/ui/components/hash/hash.tsx b/src/libs/ui/components/hash/hash.tsx index 1b5899f19..351ae6bd2 100644 --- a/src/libs/ui/components/hash/hash.tsx +++ b/src/libs/ui/components/hash/hash.tsx @@ -2,11 +2,13 @@ import React, { useMemo } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import styled from 'styled-components'; -import { CenteredFlexRow } from '@libs/layout'; +import { useIsDarkMode } from '@hooks/use-is-dark-mode'; + +import { CenteredFlexRow, SpacingSize } from '@libs/layout'; import { CopyToClipboard, Placement, - Tag, + SvgIcon, Tooltip, Typography } from '@libs/ui/components'; @@ -36,7 +38,8 @@ interface HashProps { truncatedSize?: TruncateKeySize; color?: ContentColor; withCopyOnSelfClick?: boolean; - withTag?: boolean; + isImported?: boolean; + isLedger?: boolean; placement?: Placement; withoutTooltip?: boolean; } @@ -47,12 +50,14 @@ export function Hash({ withCopyOnSelfClick = true, truncated, color, - withTag, + isImported, truncatedSize, placement, - withoutTooltip = false + withoutTooltip = false, + isLedger }: HashProps) { const { t } = useTranslation(); + const isDarkMode = useIsDarkMode(); const HashComponent = useMemo( () => ( @@ -70,12 +75,27 @@ export function Hash({ {truncated ? truncateKey(value, { size: truncatedSize }) : value} - {withTag && ( - {`${t('Imported')}`} + {isImported && ( + + )} + {isLedger && ( + )} ), [ + isDarkMode, truncated, withoutTooltip, value, @@ -83,8 +103,8 @@ export function Hash({ variant, color, truncatedSize, - withTag, - t + isImported, + isLedger ] ); @@ -98,7 +118,9 @@ export function Hash({ Copied! ) : ( - {HashComponent} + + {HashComponent} + )} )} @@ -106,5 +128,6 @@ export function Hash({ /> ); } - return {HashComponent}; + + return {HashComponent}; } diff --git a/src/libs/ui/components/index.ts b/src/libs/ui/components/index.ts index 3b2070954..eaf008ce1 100644 --- a/src/libs/ui/components/index.ts +++ b/src/libs/ui/components/index.ts @@ -52,3 +52,9 @@ export * from './theme-switcher/theme-switcher'; export * from './identicon/identicon'; export * from './spinner/spinner'; export * from './tips/tips'; +export * from './review-with-ledger/review-with-ledger'; +export * from './no-connected-ledger/no-connected-ledger'; +export * from './ledger-footer/ledger-footer'; +export * from './ledger-error-view/ledger-error-view'; +export * from './ledger-event-view/ledger-event-view'; +export * from './ledger-connection-view/ledger-connection-view'; diff --git a/src/libs/ui/components/input/input.tsx b/src/libs/ui/components/input/input.tsx index 690a8baf2..9d4e32ead 100644 --- a/src/libs/ui/components/input/input.tsx +++ b/src/libs/ui/components/input/input.tsx @@ -15,7 +15,15 @@ const getThemeColorByError = (error?: boolean) => { }; const InputContainer = styled('div')( - ({ theme, oneColoredIcons, disabled, error, monotype, readOnly }) => ({ + ({ + theme, + oneColoredIcons, + disabled, + error, + monotype, + readOnly, + secondaryBackground + }) => ({ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', @@ -23,7 +31,9 @@ const InputContainer = styled('div')( padding: '0 16px', borderRadius: theme.borderRadius.base, color: theme.color.contentPrimary, - background: theme.color.backgroundPrimary, + background: secondaryBackground + ? theme.color.backgroundSecondary + : theme.color.backgroundPrimary, caretColor: theme.color.fillCritical, fontFamily: monotype ? theme.typography.fontFamily.mono @@ -139,6 +149,7 @@ export interface InputProps extends BaseProps { validationText?: string | null; dataTestId?: string; autoComplete?: string; + secondaryBackground?: boolean; } export const Input = forwardRef(function Input( @@ -162,6 +173,7 @@ export const Input = forwardRef(function Input( dataTestId, readOnly, autoComplete, + secondaryBackground, ...restProps }: InputProps, ref @@ -200,6 +212,7 @@ export const Input = forwardRef(function Input( error={error} height={height} oneColoredIcons={oneColoredIcons} + secondaryBackground={secondaryBackground} > {prefixIcon && {prefixIcon}} diff --git a/src/libs/ui/components/ledger-connection-view/ledger-connection-view.tsx b/src/libs/ui/components/ledger-connection-view/ledger-connection-view.tsx new file mode 100644 index 000000000..ff3dbf61f --- /dev/null +++ b/src/libs/ui/components/ledger-connection-view/ledger-connection-view.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { RouterPath, useTypedNavigate } from '@popup/router'; + +import { + HeaderPopup, + HeaderSubmenuBarNavLink, + PopupLayout +} from '@libs/layout'; +import { ILedgerEvent, LedgerTransport } from '@libs/services/ledger'; +import { LedgerEventView, renderLedgerFooter } from '@libs/ui/components'; + +interface INotConnectedLedgerProps { + event: ILedgerEvent; + onConnect: (tr?: LedgerTransport) => () => Promise; + closeNewLedgerWindowsAndClearState: () => void; + isAccountSelection?: boolean; +} + +export const LedgerConnectionView: React.FC = ({ + event, + onConnect, + closeNewLedgerWindowsAndClearState, + isAccountSelection = false +}) => { + const navigate = useTypedNavigate(); + + const onErrorCtaPressed = () => { + closeNewLedgerWindowsAndClearState(); + navigate(RouterPath.Home); + }; + + return ( + ( + ( + + )} + /> + )} + renderContent={() => ( + + )} + renderFooter={renderLedgerFooter({ + event, + onConnect, + onErrorCtaPressed + })} + /> + ); +}; diff --git a/src/libs/ui/components/ledger-error-view/ledger-error-view.tsx b/src/libs/ui/components/ledger-error-view/ledger-error-view.tsx new file mode 100644 index 000000000..56f668dfc --- /dev/null +++ b/src/libs/ui/components/ledger-error-view/ledger-error-view.tsx @@ -0,0 +1,92 @@ +import { Player } from '@lottiefiles/react-lottie-player'; +import React, { useEffect } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { useIsDarkMode } from '@hooks/use-is-dark-mode'; + +import dotsDarkModeAnimation from '@libs/animations/dots_dark_mode.json'; +import dotsLightModeAnimation from '@libs/animations/dots_light_mode.json'; +import { + CenteredFlexColumn, + ContentContainer, + IllustrationContainer, + ParagraphContainer, + SpacingSize +} from '@libs/layout'; +import { + ILedgerEvent, + LedgerEventStatus, + ledgerErrorsData +} from '@libs/services/ledger'; +import { SvgIcon, Typography } from '@libs/ui/components'; + +interface ILedgerErrorProps { + event: ILedgerEvent; +} + +export const LedgerErrorView: React.FC = ({ event }) => { + const { t } = useTranslation(); + const isDarkMode = useIsDarkMode(); + + const title = ledgerErrorsData[event.status]?.title; + const description = ledgerErrorsData[event.status]?.description; + + const isRejectedIcon = + event.status === LedgerEventStatus.CasperAppNotLoaded || + event.status === LedgerEventStatus.MsgSignatureCanceled || + event.status === LedgerEventStatus.SignatureCanceled; + + const withLoader = + event.status === LedgerEventStatus.CasperAppNotLoaded || + event.status === LedgerEventStatus.DeviceLocked; + + useEffect(() => { + const container = document.querySelector('#ms-container'); + + container?.scrollTo(0, 0); + }, []); + + if (!title) { + return null; + } + + return ( + + + + + + + {title} + + + {description && ( + + + {description} + + + )} + + {withLoader && ( + + + + )} + + ); +}; diff --git a/src/libs/ui/components/ledger-event-view/ledger-event-view.tsx b/src/libs/ui/components/ledger-event-view/ledger-event-view.tsx new file mode 100644 index 000000000..052f35897 --- /dev/null +++ b/src/libs/ui/components/ledger-event-view/ledger-event-view.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; + +import { + ILedgerEvent, + LedgerEventStatus, + isLedgerError, + isTransportAvailable +} from '@libs/services/ledger'; +import { + LedgerErrorView, + NoConnectedLedger, + ReviewWithLedger +} from '@libs/ui/components'; + +interface ILedgerEventViewProps { + event: ILedgerEvent; + isAccountSelection?: boolean; +} + +export const LedgerEventView: React.FC = ({ + event, + isAccountSelection = false +}) => { + const [available, setAvailable] = useState(true); + + useEffect(() => { + isTransportAvailable().then(setAvailable); + }, []); + + if (!available) { + return ( + + ); + } + + if ( + event.status === LedgerEventStatus.SignatureRequestedToUser && + event.deployHash + ) { + return ; + } + + if ( + event.status === LedgerEventStatus.MsgSignatureRequestedToUser && + event.msgHash + ) { + return ; + } + + if (isLedgerError(event)) { + return ; + } + + return ( + + ); +}; diff --git a/src/libs/ui/components/ledger-footer/ledger-disconnected-footer.tsx b/src/libs/ui/components/ledger-footer/ledger-disconnected-footer.tsx new file mode 100644 index 000000000..e5db4c210 --- /dev/null +++ b/src/libs/ui/components/ledger-footer/ledger-disconnected-footer.tsx @@ -0,0 +1,111 @@ +import { Player } from '@lottiefiles/react-lottie-player'; +import React, { useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { ledgerStateCleared } from '@background/redux/ledger/actions'; +import { dispatchToMainStore } from '@background/redux/utils'; + +import { useIsDarkMode } from '@hooks/use-is-dark-mode'; + +import dotsDarkModeAnimation from '@libs/animations/dots_dark_mode.json'; +import dotsLightModeAnimation from '@libs/animations/dots_light_mode.json'; +import { CenteredFlexColumn, FooterButtonsContainer } from '@libs/layout'; +import { + IsUsbLedgerTransportAvailable, + LedgerTransport, + subscribeToBluetoothAvailability +} from '@libs/services/ledger'; +import { Button } from '@libs/ui/components'; + +interface ILedgerDisconnectedFooterProps { + onConnect: (tr?: LedgerTransport) => () => Promise; +} + +export const LedgerDisconnectedFooter: React.FC< + ILedgerDisconnectedFooterProps +> = ({ onConnect }) => { + const { t } = useTranslation(); + const searchParams = new URLSearchParams(document.location.search); + const ledgerTransport = searchParams.get('ledgerTransport'); + + const isDarkMode = useIsDarkMode(); + const [isPlayingLoading, setIsPlayingLoading] = useState(false); + const [usbAvailable, setUsbAvailable] = useState(true); + const [bluetoothAvailable, setBluetoothAvailable] = useState(true); + + useEffect(() => { + IsUsbLedgerTransportAvailable().then(setUsbAvailable); + }, []); + + useEffect(() => { + const sub = subscribeToBluetoothAvailability(setBluetoothAvailable); + + return () => sub.unsubscribe(); + }, []); + + return ( + + {isPlayingLoading ? ( + + + + ) : ( + <> + {usbAvailable && + (ledgerTransport ? ledgerTransport === 'USB' : true) && ( + + )} + {bluetoothAvailable && + (ledgerTransport ? ledgerTransport === 'Bluetooth' : true) && ( + + )} + + )} + + ); +}; diff --git a/src/libs/ui/components/ledger-footer/ledger-footer.tsx b/src/libs/ui/components/ledger-footer/ledger-footer.tsx new file mode 100644 index 000000000..eac015ea4 --- /dev/null +++ b/src/libs/ui/components/ledger-footer/ledger-footer.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { FooterButtonsContainer } from '@libs/layout'; +import { + ILedgerEvent, + LedgerEventStatus, + LedgerTransport, + isLedgerError +} from '@libs/services/ledger'; +import { Button } from '@libs/ui/components'; + +import { LedgerDisconnectedFooter } from './ledger-disconnected-footer'; + +interface IRenderLedgerFooterParams { + event: ILedgerEvent; + onErrorCtaPressed: () => void; + onConnect: (tr?: LedgerTransport) => () => Promise; +} + +export const renderLedgerFooter = ({ + event, + onErrorCtaPressed, + onConnect +}: IRenderLedgerFooterParams) => { + if ( + event?.status === LedgerEventStatus.Disconnected || + event?.status === LedgerEventStatus.LedgerAskPermission + ) { + return () => ; + } else if (isLedgerError(event)) { + return () => ; + } + + return undefined; +}; + +export const LedgerErrorFooter: React.FC< + Pick +> = ({ onErrorCtaPressed }) => { + const { t } = useTranslation(); + + return ( + + + + ); +}; diff --git a/src/libs/ui/components/list/list.tsx b/src/libs/ui/components/list/list.tsx index f5ef9365a..4ff895b55 100644 --- a/src/libs/ui/components/list/list.tsx +++ b/src/libs/ui/components/list/list.tsx @@ -81,6 +81,7 @@ interface ListProps { maxHeight?: number; borderRadius?: 'base'; height?: number; + maxItemsToRender?: number; } export function List({ @@ -97,7 +98,8 @@ export function List({ stickyHeader, maxHeight, borderRadius, - height + height, + maxItemsToRender }: ListProps) { const separatorLine = marginLeftForHeaderSeparatorLine || marginLeftForHeaderSeparatorLine === 0 @@ -153,11 +155,19 @@ export function List({ - {rows.map((row, index, array) => ( - - {renderRow(row, index, array)} - - ))} + {maxItemsToRender + ? rows + .slice(0, maxItemsToRender) + .map((row, index, array) => ( + + {renderRow(row, index, array)} + + )) + : rows.map((row, index, array) => ( + + {renderRow(row, index, array)} + + ))} )} diff --git a/src/libs/ui/components/no-connected-ledger/no-connected-ledger.tsx b/src/libs/ui/components/no-connected-ledger/no-connected-ledger.tsx new file mode 100644 index 000000000..c7a857ef2 --- /dev/null +++ b/src/libs/ui/components/no-connected-ledger/no-connected-ledger.tsx @@ -0,0 +1,156 @@ +import { Player } from '@lottiefiles/react-lottie-player'; +import React, { useEffect, useMemo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +import { ledgerSupportLink } from '@src/constants'; + +import { useIsDarkMode } from '@hooks/use-is-dark-mode'; + +import dotsDarkModeAnimation from '@libs/animations/dots_dark_mode.json'; +import dotsLightModeAnimation from '@libs/animations/dots_light_mode.json'; +import { + AlignedFlexRow, + CenteredFlexColumn, + ContentContainer, + IllustrationContainer, + ParagraphContainer, + SpacingSize +} from '@libs/layout'; +import { ILedgerEvent, LedgerEventStatus } from '@libs/services/ledger'; +import { Link, List, SvgIcon, Typography } from '@libs/ui/components'; + +const ItemContainer = styled(AlignedFlexRow)` + padding: 16px; +`; + +interface INoConnectedLedgerProps { + event?: ILedgerEvent; + isAccountSelection: boolean; +} + +export const NoConnectedLedger: React.FC = ({ + event, + isAccountSelection +}) => { + const { t } = useTranslation(); + const isDarkMode = useIsDarkMode(); + + const steps = useMemo( + () => [ + { + id: 1, + text: 'Connect Ledger to your device' + }, + { + id: 2, + text: 'Open Casper app on your Ledger' + }, + { + id: 3, + text: isAccountSelection + ? 'Get back here to see list of accounts' + : 'Get back here to see Txn hash' + } + ], + [isAccountSelection] + ); + + useEffect(() => { + const container = document.querySelector('#ms-container'); + + container?.scrollTo(0, 0); + }, []); + + if ( + !( + event?.status === LedgerEventStatus.Disconnected || + event?.status === LedgerEventStatus.WaitingResponseFromDevice || + event?.status === LedgerEventStatus.LedgerAskPermission + ) + ) { + return null; + } + + return ( + + + {event.status === LedgerEventStatus.WaitingResponseFromDevice ? ( + + ) : ( + + )} + + + + {event.status === LedgerEventStatus.WaitingResponseFromDevice && ( + Ledger is connecting + )} + {event.status === LedgerEventStatus.Disconnected && ( + Open the Casper app on your Ledger device + )} + {event.status === LedgerEventStatus.LedgerAskPermission && ( + Next, approve access to your Ledger device + )} + + + + {event.status === LedgerEventStatus.WaitingResponseFromDevice && ( + + + Follow the steps to be able to [Sign/Confirm] transaction with + Ledger. + + + )} + {event.status === LedgerEventStatus.Disconnected && ( + + + Learn more about Ledger + + + )} + + + {event.status === LedgerEventStatus.WaitingResponseFromDevice && ( + ( + + + + {text} + + + )} + marginLeftForItemSeparatorLine={56} + /> + )} + + {event.status === LedgerEventStatus.WaitingResponseFromDevice && ( + + + + )} + + ); +}; diff --git a/src/libs/ui/components/review-with-ledger/review-with-ledger.tsx b/src/libs/ui/components/review-with-ledger/review-with-ledger.tsx new file mode 100644 index 000000000..1190c46ad --- /dev/null +++ b/src/libs/ui/components/review-with-ledger/review-with-ledger.tsx @@ -0,0 +1,67 @@ +import { Player } from '@lottiefiles/react-lottie-player'; +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +import { useIsDarkMode } from '@hooks/use-is-dark-mode'; + +import dotsDarkModeAnimation from '@libs/animations/dots_dark_mode.json'; +import dotsLightModeAnimation from '@libs/animations/dots_light_mode.json'; +import { + CenteredFlexColumn, + ContentContainer, + ParagraphContainer, + SpacingSize, + VerticalSpaceContainer +} from '@libs/layout'; +import { FormField, TextArea, Typography } from '@libs/ui/components'; + +interface ReviewWithLedgerProps { + hash: string; + hashLabel: string; +} + +const HeaderTextContainer = styled(ParagraphContainer)` + // We are using this instead of 'top' prop in , because there is a problem with height when we call it in layout window + padding-top: 24px; +`; + +export const ReviewWithLedger = ({ + hash, + hashLabel +}: ReviewWithLedgerProps) => { + const { t } = useTranslation(); + const isDarkMode = useIsDarkMode(); + + return ( + + + + Review and sign with Ledger + + + + + {`Compare the ${hashLabel.toLowerCase()} on your Ledger device with the value + below and approve or reject the signature.`} + + + + +