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.`}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/libs/ui/global-style.ts b/src/libs/ui/global-style.ts
index 34c56a977..cffd17a80 100644
--- a/src/libs/ui/global-style.ts
+++ b/src/libs/ui/global-style.ts
@@ -30,6 +30,9 @@ export const GlobalStyle = createGlobalStyle<{ theme: any }>`
border-radius: 16px;
color: ${props => props.theme.color.contentPrimary};
+
+ display: flex;
+ justify-content: center;
}
* {
diff --git a/src/libs/ui/utils/get-color-from-theme.ts b/src/libs/ui/utils/get-color-from-theme.ts
index 20e25caf9..79434182b 100644
--- a/src/libs/ui/utils/get-color-from-theme.ts
+++ b/src/libs/ui/utils/get-color-from-theme.ts
@@ -12,7 +12,8 @@ export type ContentColor =
| 'contentPositive'
| 'contentGreenStatus'
| 'contentLightBlue'
- | 'brandRed';
+ | 'brandRed'
+ | 'black';
export type BackgroundColor =
| 'inherit'
@@ -55,6 +56,7 @@ export function getColorFromTheme(theme: DefaultTheme, color: Color) {
fillPrimaryClick: theme.color.fillPrimaryClick,
fillCriticalHover: theme.color.fillCriticalHover,
fillCriticalClick: theme.color.fillCriticalClick,
- fillNeutral: theme.color.fillNeutral
+ fillNeutral: theme.color.fillNeutral,
+ black: theme.color.black
}[color];
}
diff --git a/src/utils.ts b/src/utils.ts
index 8d7d48efa..63a7cf3b3 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -36,6 +36,10 @@ export const isSafariBuild = process.env.BROWSER === Browser.Safari;
export const isFirefoxBuild = process.env.BROWSER === Browser.Firefox;
export const isChromeBuild = process.env.BROWSER === Browser.Chrome;
+export const isLedgerAvailable =
+ process.env.BROWSER === Browser.Chrome ||
+ process.env.BROWSER === Browser.Edge;
+
export const isValidU64 = (value?: string): boolean => {
if (!value) {
return false;
@@ -342,6 +346,10 @@ export const findMediaPreview = (metadata: NFTTokenMetadataEntry): boolean => {
};
export const isEqualCaseInsensitive = (key1: string, key2: string) => {
+ if (!(key1 && key2)) {
+ return false;
+ }
+
return key1.toLowerCase() === key2.toLowerCase();
};
diff --git a/xcode-project/Casper Wallet/Casper Wallet.xcodeproj/project.pbxproj b/xcode-project/Casper Wallet/Casper Wallet.xcodeproj/project.pbxproj
index 5e961c1be..4f85ea8b2 100644
--- a/xcode-project/Casper Wallet/Casper Wallet.xcodeproj/project.pbxproj
+++ b/xcode-project/Casper Wallet/Casper Wallet.xcodeproj/project.pbxproj
@@ -564,7 +564,7 @@
CODE_SIGN_ENTITLEMENTS = "Casper Wallet/Casper Wallet.entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 57;
+ CURRENT_PROJECT_VERSION = 64;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Casper Wallet/Info.plist";
@@ -578,7 +578,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.9.1;
+ MARKETING_VERSION = 1.10.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -601,7 +601,7 @@
CODE_SIGN_ENTITLEMENTS = "Casper Wallet/Casper Wallet.entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 57;
+ CURRENT_PROJECT_VERSION = 64;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Casper Wallet/Info.plist";
@@ -615,7 +615,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.9.1;
+ MARKETING_VERSION = 1.10.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,