diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 3bab067b90..e7e34fac58 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -811,6 +811,53 @@ export async function importHardwareWalletFlow( await findElementByText(driver, 'Rainbow is ready to use'); } +export async function importGridPlusWallet(driver: WebDriver, rootURL: string) { + await goToWelcome(driver, rootURL); + await findElementByTestIdAndClick({ + id: 'import-wallet-button', + driver, + }); + await findElementByTestIdAndClick({ + id: 'connect-wallet-option', + driver, + }); + await findElementByTestIdAndClick({ + id: `gridplus-option`, + driver, + }); + const inputDeviceId = await findElementByTestId({ + id: 'gridplus-deviceid', + driver, + }); + await inputDeviceId.sendKeys('MOCKED_DEVICE_ID'); + const inputPassword = await findElementByTestId({ + id: 'gridplus-password', + driver, + }); + await inputPassword.sendKeys('MOCKED_PASSWORD'); + await delayTime('long'); + await findElementByTestIdAndClick({ + id: 'gridplus-submit', + driver, + }); + await findElementByTestIdAndClick({ + id: 'gridplus-address-0', + driver, + }); + await findElementByTestIdAndClick({ + id: 'gridplus-submit', + driver, + }); + await findElementByTestIdAndClick({ + id: 'connect-wallets-button', + driver, + }); + await findElementByTestIdAndClick({ + id: 'hw-done', + driver, + }); +} + export async function importWalletFlowUsingKeyboardNavigation( driver: WebDriver, rootURL: string, diff --git a/e2e/parallel/GridPlusImportFlow.test.ts b/e2e/parallel/GridPlusImportFlow.test.ts new file mode 100644 index 0000000000..c7f1e0eef7 --- /dev/null +++ b/e2e/parallel/GridPlusImportFlow.test.ts @@ -0,0 +1,37 @@ +import 'chromedriver'; +import 'geckodriver'; +import { WebDriver } from 'selenium-webdriver'; +import { afterAll, beforeAll, describe, it } from 'vitest'; + +import { + getExtensionIdByName, + getRootUrl, + importGridPlusWallet, + initDriverWithOptions, +} from '../helpers'; + +let rootURL = getRootUrl(); +let driver: WebDriver; + +const browser = process.env.BROWSER || 'chrome'; +const os = process.env.OS || 'mac'; + +describe.runIf(browser !== 'firefox')( + 'Import wallet with GridPlus Lattice1', + () => { + beforeAll(async () => { + driver = await initDriverWithOptions({ + browser, + os, + }); + const extensionId = await getExtensionIdByName(driver, 'Rainbow'); + if (!extensionId) throw new Error('Extension not found'); + rootURL += extensionId; + }); + afterAll(async () => driver.quit()); + + it('should be able import a wallet via hw wallet', async () => { + await importGridPlusWallet(driver, rootURL); + }); + }, +); diff --git a/lavamoat/build-webpack/policy.json b/lavamoat/build-webpack/policy.json index 8d15a9aa99..8cc27c9697 100644 --- a/lavamoat/build-webpack/policy.json +++ b/lavamoat/build-webpack/policy.json @@ -841,6 +841,22 @@ "process.nextTick": true } }, + "gridplus-sdk>secp256k1>node-gyp-build": { + "builtin": { + "fs.existsSync": true, + "fs.readdirSync": true, + "os.arch": true, + "os.platform": true, + "path.dirname": true, + "path.join": true, + "path.resolve": true + }, + "globals": { + "__non_webpack_require__": true, + "__webpack_require__": true, + "process": true + } + }, "happy-dom>he": { "globals": { "define": true @@ -1099,13 +1115,13 @@ "eslint>debug": true, "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/code-frame": true, "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/generator": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-environment-visitor": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-hoist-variables": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-split-export-declaration": true, "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/parser": true, "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/types": true, - "jest>@jest/core>jest-snapshot>@babel/traverse>globals": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-environment-visitor": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-hoist-variables": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-split-export-declaration": true + "jest>@jest/core>jest-snapshot>@babel/traverse>globals": true } }, "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/code-frame": { @@ -1172,141 +1188,141 @@ "define": true } }, - "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/types": { - "globals": { - "console.warn": true, - "process.env.BABEL_TYPES_8_BREAKING": true - }, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name": { "packages": { - "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/types>@babel/helper-string-parser": true, - "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/types>@babel/helper-validator-identifier": true, - "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/types": true } }, - "jest>@jest/core>jest-snapshot>@babel/types": { - "globals": { - "console.trace": true, - "process.env.BABEL_TYPES_8_BREAKING": true - }, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template": { "packages": { - "jest>@jest/core>jest-snapshot>@babel/types>@babel/helper-string-parser": true, - "jest>@jest/core>jest-snapshot>@babel/types>@babel/helper-validator-identifier": true, - "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/parser": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/types": true } }, - "lavamoat>@babel/highlight": { + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame": { + "globals": { + "console.warn": true, + "process.emitWarning": true + }, "packages": { - "lavamoat>@babel/highlight>@babel/helper-validator-identifier": true, - "lavamoat>@babel/highlight>chalk": true, - "react>loose-envify>js-tokens": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk": true, + "lavamoat>@babel/highlight": true } }, - "lavamoat>@babel/highlight>chalk": { + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk": { "globals": { "process.env.TERM": true, "process.platform": true }, "packages": { - "lavamoat>@babel/highlight>chalk>ansi-styles": true, - "lavamoat>@babel/highlight>chalk>escape-string-regexp": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>escape-string-regexp": true, "supports-color": true } }, - "lavamoat>@babel/highlight>chalk>ansi-styles": { + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles": { "packages": { - "lavamoat>@babel/highlight>chalk>ansi-styles>color-convert": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles>color-convert": true } }, - "lavamoat>@babel/highlight>chalk>ansi-styles>color-convert": { + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles>color-convert": { "packages": { - "lavamoat>@babel/highlight>chalk>ansi-styles>color-convert>color-name": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles>color-convert>color-name": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name": { + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/types": { + "globals": { + "console.warn": true, + "process.env.BABEL_TYPES_8_BREAKING": true + }, "packages": { - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/types": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/types>@babel/helper-string-parser": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-function-name>@babel/types>@babel/helper-validator-identifier": true, + "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template": { + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-hoist-variables": { "packages": { - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/parser": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/types": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-hoist-variables>@babel/types": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame": { + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-hoist-variables>@babel/types": { "globals": { "console.warn": true, - "process.emitWarning": true + "process.env.BABEL_TYPES_8_BREAKING": true }, "packages": { - "lavamoat>@babel/highlight": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-hoist-variables>@babel/types>@babel/helper-string-parser": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-hoist-variables>@babel/types>@babel/helper-validator-identifier": true, + "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk": { - "globals": { - "process.env.TERM": true, - "process.platform": true - }, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-split-export-declaration": { "packages": { - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>escape-string-regexp": true, - "supports-color": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-split-export-declaration>@babel/types": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles": { + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-split-export-declaration>@babel/types": { + "globals": { + "console.warn": true, + "process.env.BABEL_TYPES_8_BREAKING": true + }, "packages": { - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles>color-convert": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-split-export-declaration>@babel/types>@babel/helper-string-parser": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/helper-split-export-declaration>@babel/types>@babel/helper-validator-identifier": true, + "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles>color-convert": { + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/types": { + "globals": { + "console.warn": true, + "process.env.BABEL_TYPES_8_BREAKING": true + }, "packages": { - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/template>@babel/code-frame>chalk>ansi-styles>color-convert>color-name": true + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/types>@babel/helper-string-parser": true, + "jest>@jest/core>jest-snapshot>@babel/traverse>@babel/types>@babel/helper-validator-identifier": true, + "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/types": { + "jest>@jest/core>jest-snapshot>@babel/types": { "globals": { - "console.warn": true, + "console.trace": true, "process.env.BABEL_TYPES_8_BREAKING": true }, "packages": { - "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/types>@babel/helper-string-parser": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-function-name>@babel/types>@babel/helper-validator-identifier": true + "jest>@jest/core>jest-snapshot>@babel/types>@babel/helper-string-parser": true, + "jest>@jest/core>jest-snapshot>@babel/types>@babel/helper-validator-identifier": true, + "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-hoist-variables": { + "lavamoat>@babel/highlight": { "packages": { - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-hoist-variables>@babel/types": true + "lavamoat>@babel/highlight>@babel/helper-validator-identifier": true, + "lavamoat>@babel/highlight>chalk": true, + "react>loose-envify>js-tokens": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-hoist-variables>@babel/types": { + "lavamoat>@babel/highlight>chalk": { "globals": { - "console.warn": true, - "process.env.BABEL_TYPES_8_BREAKING": true + "process.env.TERM": true, + "process.platform": true }, "packages": { - "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-hoist-variables>@babel/types>@babel/helper-string-parser": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-hoist-variables>@babel/types>@babel/helper-validator-identifier": true + "lavamoat>@babel/highlight>chalk>ansi-styles": true, + "lavamoat>@babel/highlight>chalk>escape-string-regexp": true, + "supports-color": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-split-export-declaration": { + "lavamoat>@babel/highlight>chalk>ansi-styles": { "packages": { - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-split-export-declaration>@babel/types": true + "lavamoat>@babel/highlight>chalk>ansi-styles>color-convert": true } }, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-split-export-declaration>@babel/types": { - "globals": { - "console.warn": true, - "process.env.BABEL_TYPES_8_BREAKING": true - }, + "lavamoat>@babel/highlight>chalk>ansi-styles>color-convert": { "packages": { - "lavamoat>lavamoat-core>@babel/types>to-fast-properties": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-split-export-declaration>@babel/types>@babel/helper-string-parser": true, - "lavamoat>lavamoat-tofu>@babel/traverse>@babel/helper-split-export-declaration>@babel/types>@babel/helper-validator-identifier": true + "lavamoat>@babel/highlight>chalk>ansi-styles>color-convert>color-name": true } }, "lint-staged>execa>merge-stream": { @@ -1526,23 +1542,7 @@ }, "native": true, "packages": { - "viem>ws>bufferutil>node-gyp-build": true - } - }, - "viem>ws>bufferutil>node-gyp-build": { - "builtin": { - "fs.existsSync": true, - "fs.readdirSync": true, - "os.arch": true, - "os.platform": true, - "path.dirname": true, - "path.join": true, - "path.resolve": true - }, - "globals": { - "__non_webpack_require__": true, - "__webpack_require__": true, - "process": true + "gridplus-sdk>secp256k1>node-gyp-build": true } }, "viem>ws>utf-8-validate": { @@ -1551,7 +1551,7 @@ }, "native": true, "packages": { - "viem>ws>bufferutil>node-gyp-build": true + "gridplus-sdk>secp256k1>node-gyp-build": true } }, "vitest>acorn": { diff --git a/package.json b/package.json index d5bb1825f7..1c24102a53 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "firebase": "9.18.0", "framer-motion": "10.2.3", "graphql-request": "5.2.0", + "gridplus-sdk": "2.5.2", "i18n-js": "4.1.1", "idna-uts46-hx": "3.5.0", "imgix-core-js": "2.3.2", @@ -230,6 +231,8 @@ "worker-loader": "3.0.8" }, "resolutions": { + "@ethereumjs/common": "4.1.0", + "@ethereumjs/tx": "5.0.0", "tar": "6.2.1", "bn.js": "5.2.1", "@scure/bip39": "1.2.1", @@ -316,10 +319,12 @@ "web-ext>sign-addon>core-js": false, "web-ext>ws>bufferutil": false, "web-ext>ws>utf-8-validate": false, + "gridplus-sdk": false, + "gridplus-sdk>secp256k1": false, "@ledgerhq/domain-service>eip55>keccak": false, "wagmi>@wagmi/core>@wagmi/connectors>@coinbase/wallet-sdk>keccak": false, "viem>ws>bufferutil": false, "viem>ws>utf-8-validate": false } } -} \ No newline at end of file +} diff --git a/src/analytics/identify/walletTypes.ts b/src/analytics/identify/walletTypes.ts index 11a1829d18..9499187cdc 100644 --- a/src/analytics/identify/walletTypes.ts +++ b/src/analytics/identify/walletTypes.ts @@ -37,6 +37,8 @@ export const identifyWalletTypes = async () => { result.ledgerDevices += 1; } else if (wallet.vendor === 'Trezor') { result.trezorDevices += 1; + } else if (wallet.vendor === 'GridPlus') { + result.gridPlusDevices += 1; } break; } @@ -53,6 +55,7 @@ export const identifyWalletTypes = async () => { hardwareAccounts: 0, ledgerDevices: 0, trezorDevices: 0, + gridPlusDevices: 0, }, ); diff --git a/src/core/keychain/hdPath.ts b/src/core/keychain/hdPath.ts index aeaad66d8c..8400f1a2bc 100644 --- a/src/core/keychain/hdPath.ts +++ b/src/core/keychain/hdPath.ts @@ -4,7 +4,7 @@ const LEGACY_LEDGER_PATH = "m/44'/60'/0'"; export const getHDPathForVendorAndType = ( index: number, - vendor?: 'Ledger' | 'Trezor', + vendor?: 'Ledger' | 'Trezor' | 'GridPlus', type?: 'legacy', ) => { switch (vendor) { diff --git a/src/core/keychain/keychainTypes/hardwareWalletKeychain.ts b/src/core/keychain/keychainTypes/hardwareWalletKeychain.ts index 2452c5eaa3..87c5cd0c0d 100644 --- a/src/core/keychain/keychainTypes/hardwareWalletKeychain.ts +++ b/src/core/keychain/keychainTypes/hardwareWalletKeychain.ts @@ -96,7 +96,7 @@ export class HardwareWalletKeychain implements IKeychain { // Backwards compatibility getHDPathForVendorAndType( wallet.index, - this.vendor as 'Ledger' | 'Trezor', + this.vendor as 'Ledger' | 'Trezor' | 'GridPlus', ) ); } diff --git a/src/core/state/gridplusClient/index.ts b/src/core/state/gridplusClient/index.ts new file mode 100644 index 0000000000..ef93a36939 --- /dev/null +++ b/src/core/state/gridplusClient/index.ts @@ -0,0 +1,23 @@ +import create from 'zustand'; + +import { createStore } from '../internal/createStore'; + +type GridPlusClientStore = { + client: string; + setClient: (client: string) => void; +}; + +export const gridPlusClientStore = createStore( + (set) => ({ + client: '', + setClient: (client) => set({ client }), + }), + { + persist: { + name: 'gridplusClient', + version: 0, + }, + }, +); + +export const useGridPlusClientStore = create(gridPlusClientStore); diff --git a/src/core/types/keychainTypes.ts b/src/core/types/keychainTypes.ts index 948efbd2a7..5ba7d3a695 100644 --- a/src/core/types/keychainTypes.ts +++ b/src/core/types/keychainTypes.ts @@ -9,5 +9,5 @@ export type KeychainWallet = { type: KeychainType; accounts: `0x${string}`[]; imported: boolean; - vendor?: 'Ledger' | 'Trezor'; + vendor?: 'Ledger' | 'Trezor' | 'GridPlus'; }; diff --git a/src/core/types/walletTypes.ts b/src/core/types/walletTypes.ts index 43d71a6064..5e1e8c2167 100644 --- a/src/core/types/walletTypes.ts +++ b/src/core/types/walletTypes.ts @@ -5,4 +5,5 @@ export enum EthereumWalletType { seed = 'seed', ledgerPublicKey = 'ledgerPublicKey', trezorPublicKey = 'trezorPublicKey', + gridPlusPublicKey = 'gridPlusPublicKey', } diff --git a/src/entries/popup/App.tsx b/src/entries/popup/App.tsx index dae8dfafbd..1d6203f18a 100644 --- a/src/entries/popup/App.tsx +++ b/src/entries/popup/App.tsx @@ -22,6 +22,7 @@ import { Routes } from './Routes'; import { HWRequestListener } from './components/HWRequestListener/HWRequestListener'; import { IdleTimer } from './components/IdleTimer/IdleTimer'; import { OnboardingKeepAlive } from './components/OnboardingKeepAlive'; +import { useGridPlusInit } from './handlers/gridplusHooks'; import { AuthProvider } from './hooks/useAuth'; import { useExpiryListener } from './hooks/useExpiryListener'; import { useIsFullScreen } from './hooks/useIsFullScreen'; @@ -40,6 +41,7 @@ export function App() { const prevChains = usePrevious(rainbowChains); useExpiryListener(); + useGridPlusInit(); React.useEffect(() => { if (!isEqual(prevChains, rainbowChains)) { diff --git a/src/entries/popup/Routes.tsx b/src/entries/popup/Routes.tsx index e6e183681e..166939c8ca 100644 --- a/src/entries/popup/Routes.tsx +++ b/src/entries/popup/Routes.tsx @@ -30,6 +30,7 @@ import { ImportWalletViaSeed } from './components/ImportWallet/ImportWalletViaSe import { Toast } from './components/Toast/Toast'; import { UnsupportedBrowserSheet } from './components/UnsupportedBrowserSheet'; import { WindowStroke } from './components/WindowStroke/WindowStroke'; +import { useGridPlusPermissions } from './handlers/gridplusHooks'; import { useCommandKShortcuts } from './hooks/useCommandKShortcuts'; import useKeyboardAnalytics from './hooks/useKeyboardAnalytics'; import { useKeyboardShortcut } from './hooks/useKeyboardShortcut'; @@ -46,6 +47,7 @@ import { PointsReferralSheet } from './pages/home/Points/PointsReferralSheet'; import { PointsWeeklyOverview } from './pages/home/Points/WeeklyPointsOverview'; import { TokenDetails } from './pages/home/TokenDetails/TokenDetails'; import { ChooseHW } from './pages/hw/chooseHW'; +import { ConnectGridPlus } from './pages/hw/gridplus'; import { ConnectLedger } from './pages/hw/ledger'; import { SuccessHW } from './pages/hw/success'; import { ConnectTrezor } from './pages/hw/trezor'; @@ -331,6 +333,19 @@ const ROUTE_DATA = [ ), }, + { + path: ROUTES.HW_GRIDPLUS, + element: ( + + + + ), + }, { path: ROUTES.HW_WALLET_LIST, element: ( @@ -984,6 +999,7 @@ const RootLayout = () => { useGlobalShortcuts(); useCommandKShortcuts(); + useGridPlusPermissions(); return ( diff --git a/src/entries/popup/components/Checkbox/Checkbox.tsx b/src/entries/popup/components/Checkbox/Checkbox.tsx index 68ce35ef4e..7763eff5d4 100644 --- a/src/entries/popup/components/Checkbox/Checkbox.tsx +++ b/src/entries/popup/components/Checkbox/Checkbox.tsx @@ -37,11 +37,11 @@ export function Checkbox({ justifyContent="center" display="flex" onClick={onClick} + testId={testId} style={{ width: width || '18px', height: height || '18px', }} - testId={testId} > {selected && ( diff --git a/src/entries/popup/components/HWRequestListener/HWRequestListener.tsx b/src/entries/popup/components/HWRequestListener/HWRequestListener.tsx index 9198443e4d..233f2bef2e 100644 --- a/src/entries/popup/components/HWRequestListener/HWRequestListener.tsx +++ b/src/entries/popup/components/HWRequestListener/HWRequestListener.tsx @@ -17,7 +17,7 @@ const bgMessenger = initializeMessenger({ connect: 'background' }); interface HWSigningRequest { action: 'signTransaction' | 'signMessage' | 'signTypedData'; - vendor: 'Ledger' | 'Trezor'; + vendor: 'Ledger' | 'Trezor' | 'GridPlus'; payload: | TransactionRequest | { message: string; address: string } diff --git a/src/entries/popup/handlers/gridplus.ts b/src/entries/popup/handlers/gridplus.ts new file mode 100644 index 0000000000..1fe5497251 --- /dev/null +++ b/src/entries/popup/handlers/gridplus.ts @@ -0,0 +1,245 @@ +import { Common, Hardfork } from '@ethereumjs/common'; +import { TransactionFactory, TypedTxData } from '@ethereumjs/tx'; +import { + TransactionRequest, + TransactionResponse, +} from '@ethersproject/abstract-provider'; +import { BigNumber } from '@ethersproject/bignumber'; +import { Bytes, hexlify, joinSignature } from '@ethersproject/bytes'; +import { + UnsignedTransaction, + parse, + serialize, +} from '@ethersproject/transactions'; +import { TypedDataUtils } from '@metamask/eth-sig-util'; +import { getProvider } from '@wagmi/core'; +import { + Constants, + sign as gridPlusSign, + signMessage as gridPlusSignMessage, +} from 'gridplus-sdk'; +import { encode } from 'rlp'; +import { Address } from 'wagmi'; + +import { getPath } from '~/core/keychain'; +import { LocalStorage } from '~/core/storage'; +import { addHexPrefix } from '~/core/utils/hex'; + +const LOCAL_STORAGE_CLIENT_NAME = 'storedClient'; + +export const getStoredGridPlusClient = () => + LocalStorage.get(LOCAL_STORAGE_CLIENT_NAME) ?? ''; + +export const setStoredGridPlusClient = (storedClient: string | null) => { + if (!storedClient) return; + LocalStorage.set(LOCAL_STORAGE_CLIENT_NAME, storedClient); +}; + +export const removeStoredGridPlusClient = () => + LocalStorage.remove(LOCAL_STORAGE_CLIENT_NAME); + +export async function signTransactionFromGridPlus( + transaction: TransactionRequest, +) { + try { + const { from: address } = transaction; + const path = await getPath(address as Address); + const addressIndex = parseInt(path.split('/')[5]); + const signerPath = [ + 0x80000000 + 44, + 0x80000000 + 60, + 0x80000000, + 0, + addressIndex, + ]; + const baseTx: UnsignedTransaction = { + chainId: transaction.chainId, + data: transaction.data, + gasLimit: transaction.gasLimit + ? BigNumber.from(transaction.gasLimit).toHexString() + : undefined, + nonce: transaction.nonce + ? BigNumber.from(transaction.nonce).toNumber() + : undefined, + to: transaction.to, + value: transaction.value + ? BigNumber.from(transaction.value).toHexString() + : undefined, + }; + + if (transaction.gasPrice) { + baseTx.gasPrice = transaction.gasPrice; + } else { + if (transaction.maxFeePerGas && transaction.maxPriorityFeePerGas) { + baseTx.maxFeePerGas = transaction.maxFeePerGas; + baseTx.maxPriorityFeePerGas = transaction.maxPriorityFeePerGas; + baseTx.type = 2; + } else { + baseTx.gasPrice = transaction.maxFeePerGas; + } + } + + const common = Common.custom({ + chainId: transaction.chainId, + defaultHardfork: Hardfork.London, + }); + + const txPayload = TransactionFactory.fromTxData(baseTx as TypedTxData, { + common, + }); + + const signPayload = { + data: { + signerPath, + chain: transaction.chainId, + curveType: Constants.SIGNING.CURVES.SECP256K1, + hashType: Constants.SIGNING.HASHES.KECCAK256, + encodingType: Constants.SIGNING.ENCODINGS.EVM, + payload: + baseTx.type === 2 + ? txPayload.getMessageToSign() + : encode(txPayload.getMessageToSign()), + }, + }; + + const response = await gridPlusSign([], signPayload); + + const r = addHexPrefix(response.sig.r.toString('hex')); + const s = addHexPrefix(response.sig.s.toString('hex')); + const v = BigNumber.from( + addHexPrefix(response.sig.v.toString('hex')), + ).toNumber(); + + if (response.pubkey) { + const serializedTransaction = serialize(baseTx, { + r, + s, + v, + }); + + const parsedTx = parse(serializedTransaction); + if (parsedTx.from?.toLowerCase() !== address?.toLowerCase()) { + throw new Error( + 'Address not found on this wallet. Try another SafeCard or remove the SafeCard to use the wallet on your device.', + ); + } + + return serializedTransaction; + } else { + alert('error signing transaction with gridplus'); + throw new Error('error signing transaction with gridplus'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + alert('Please make sure your gridplus is unlocked'); + // bubble up the error + throw e; + } +} + +export async function sendTransactionFromGridPlus( + transaction: TransactionRequest, +): Promise { + const serializedTransaction = await signTransactionFromGridPlus(transaction); + const provider = getProvider({ + chainId: transaction.chainId, + }); + return provider.sendTransaction(serializedTransaction as string); +} + +export async function signMessageByTypeFromGridPlus( + msgData: string | Bytes, + address: Address, + messageType: string, +): Promise { + const path = await getPath(address as Address); + const addressIndex = parseInt(path.split('/')[5]); + const signerPath = [ + 0x80000000 + 44, + 0x80000000 + 60, + 0x80000000, + 0, + addressIndex, + ]; + // Personal sign + if (messageType === 'personal_sign') { + const response = await gridPlusSignMessage(msgData, { + signerPath, + payload: msgData, + protocol: 'signPersonal', + }); + + const responseAddress = hexlify(response.signer); + + if (responseAddress.toLowerCase() !== address.toLowerCase()) { + throw new Error( + 'GridPlus returned a different address than the one requested', + ); + } + + if (!response.sig) { + throw new Error('GridPlus returned an error'); + } + + const signature = joinSignature({ + r: addHexPrefix(response.sig.r.toString('hex')), + s: addHexPrefix(response.sig.s.toString('hex')), + v: BigNumber.from( + addHexPrefix(response.sig.v.toString('hex')), + ).toNumber(), + }); + + return signature; + // sign typed data + } else if (messageType === 'sign_typed_data') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsedData = msgData as any; + if ( + typeof msgData !== 'object' || + !(parsedData.types || parsedData.primaryType || parsedData.domain) + ) { + throw new Error('unsupported typed data version'); + } + + const { domain, types, primaryType, message } = + TypedDataUtils.sanitizeData(parsedData); + + const eip712Data = { + types, + primaryType, + domain, + message, + }; + + const response = await gridPlusSignMessage(eip712Data, { + signerPath, + protocol: 'eip712', + payload: eip712Data, + }); + + const responseAddress = hexlify(response.signer); + + if (responseAddress.toLowerCase() !== address.toLowerCase()) { + throw new Error( + 'Address not found on this wallet. Try another SafeCard or remove the SafeCard to use the wallet on your device.', + ); + } + + if (!response.sig) { + throw new Error('GridPlus returned an error'); + } + + const signature = joinSignature({ + r: addHexPrefix(response.sig.r.toString('hex')), + s: addHexPrefix(response.sig.s.toString('hex')), + v: BigNumber.from( + addHexPrefix(response.sig.v.toString('hex')), + ).toNumber(), + }); + + return signature; + } else { + throw new Error(`Message type ${messageType} not supported`); + } +} diff --git a/src/entries/popup/handlers/gridplusHooks.tsx b/src/entries/popup/handlers/gridplusHooks.tsx new file mode 100644 index 0000000000..4207230456 --- /dev/null +++ b/src/entries/popup/handlers/gridplusHooks.tsx @@ -0,0 +1,90 @@ +import { connect, setup } from 'gridplus-sdk'; +import * as React from 'react'; +import { Address } from 'wagmi'; + +import { useCurrentAddressStore } from '~/core/state'; +import { useGridPlusClientStore } from '~/core/state/gridplusClient'; +import { useHiddenWalletsStore } from '~/core/state/hiddenWallets'; +import { useWalletBackupsStore } from '~/core/state/walletBackups'; +import { useWalletNamesStore } from '~/core/state/walletNames'; +import { LocalStorage } from '~/core/storage'; + +import { useAccounts } from '../hooks/useAccounts'; +import { useRainbowNavigate } from '../hooks/useRainbowNavigate'; +import { ROUTES } from '../urls'; + +import { + getStoredGridPlusClient, + removeStoredGridPlusClient, + setStoredGridPlusClient, +} from './gridplus'; +import { remove, wipe } from './wallet'; + +export const useGridPlusInit = () => { + const setClient = useGridPlusClientStore((state) => state.setClient); + const setStoredClient = (storedClient: string | null) => { + if (!storedClient) return; + setStoredGridPlusClient(storedClient); + setClient(storedClient); + }; + React.useEffect(() => { + setup({ + getStoredClient: () => useGridPlusClientStore.getState().client, + setStoredClient: setStoredClient, + name: 'Rainbow', + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; + +// Handle removed permissions for Rainbow from Lattice1. +// If there are GridPlus addresses -> Remove them from Rainbow and switch to another existing address. +// If there are only GridPlus addresses -> Start over. +export const useGridPlusPermissions = () => { + const navigate = useRainbowNavigate(); + const { sortedAccounts } = useAccounts(); + const { unhideWallet } = useHiddenWalletsStore(); + const { deleteWalletName } = useWalletNamesStore(); + const { deleteWalletBackup } = useWalletBackupsStore(); + const { setCurrentAddress } = useCurrentAddressStore(); + + const handleRemoveAccount = async (address: Address) => { + unhideWallet({ address }); + await remove(address); + deleteWalletName({ address }); + deleteWalletBackup({ address }); + }; + + const checkPermissions = async () => { + if (sortedAccounts.length === 0) return; + if (await getStoredGridPlusClient()) { + const deviceId = (await LocalStorage.get('gridPlusDeviceId')) ?? ''; + connect(deviceId).then((permitted) => { + const accountsWithGridPlus = sortedAccounts.filter( + (account) => account.vendor === 'GridPlus', + ); + const nonGridPlusAccounts = sortedAccounts.filter( + (account) => account.vendor !== 'GridPlus', + ); + if (!permitted && accountsWithGridPlus.length > 0) { + accountsWithGridPlus.forEach((gridPlusAccount) => { + handleRemoveAccount(gridPlusAccount.address); + }); + removeStoredGridPlusClient(); + if (nonGridPlusAccounts.length > 0) { + setCurrentAddress(nonGridPlusAccounts[0].address); + navigate(ROUTES.HOME); + } else { + wipe(); + navigate(ROUTES.WELCOME); + } + } + }); + } + }; + + React.useEffect(() => { + checkPermissions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sortedAccounts.length]); +}; diff --git a/src/entries/popup/handlers/wallet.ts b/src/entries/popup/handlers/wallet.ts index c75698318a..9487e81d13 100644 --- a/src/entries/popup/handlers/wallet.ts +++ b/src/entries/popup/handlers/wallet.ts @@ -8,6 +8,7 @@ import { keccak256 } from '@ethersproject/keccak256'; import AppEth from '@ledgerhq/hw-app-eth'; import TransportWebHID from '@ledgerhq/hw-transport-webhid'; import { getProvider } from '@wagmi/core'; +import { fetchAddresses } from 'gridplus-sdk'; import { Address } from 'wagmi'; import { PrivateKey } from '~/core/keychain/IKeychain'; @@ -34,6 +35,11 @@ import { RainbowError, logger } from '~/logger'; import { PathOptions } from '../pages/hw/addByIndexSheet'; +import { + sendTransactionFromGridPlus, + signMessageByTypeFromGridPlus, + signTransactionFromGridPlus, +} from './gridplus'; import { sendTransactionFromLedger, signMessageByTypeFromLedger, @@ -91,6 +97,8 @@ export const signTransactionFromHW = async ( return signTransactionFromLedger(params); } else if (vendor === 'Trezor') { return signTransactionFromTrezor(params); + } else if (vendor === 'GridPlus') { + return signTransactionFromGridPlus(params); } }; @@ -154,6 +162,8 @@ export const sendTransaction = async ( return sendTransactionFromLedger(params); case 'Trezor': return sendTransactionFromTrezor(params); + case 'GridPlus': + return sendTransactionFromGridPlus(params); default: throw new Error('Unsupported hardware wallet'); } @@ -196,6 +206,8 @@ export const personalSign = async ( return signMessageByTypeFromLedger(msgData, address, 'personal_sign'); case 'Trezor': return signMessageByTypeFromTrezor(msgData, address, 'personal_sign'); + case 'GridPlus': + return signMessageByTypeFromGridPlus(msgData, address, 'personal_sign'); default: throw new Error('Unsupported hardware wallet'); } @@ -213,9 +225,14 @@ export const signTypedData = async ( switch (vendor) { case 'Ledger': return signMessageByTypeFromLedger(msgData, address, 'sign_typed_data'); - case 'Trezor': { + case 'Trezor': return signMessageByTypeFromTrezor(msgData, address, 'sign_typed_data'); - } + case 'GridPlus': + return signMessageByTypeFromGridPlus( + msgData, + address, + 'sign_typed_data', + ); default: throw new Error('Unsupported hardware wallet'); } @@ -328,7 +345,7 @@ export const exportAccount = async (address: Address, password: string) => }); export const importAccountAtIndex = async ( - type: string | 'Trezor' | 'Ledger', + type: string | 'Trezor' | 'Ledger' | 'GridPlus', index: number, currentPath?: PathOptions, ) => { @@ -366,6 +383,23 @@ export const importAccountAtIndex = async ( address = result.address; break; } + case 'GridPlus': { + try { + address = ( + await fetchAddresses({ + n: 1, + startPath: [0x80000000 + 44, 0x80000000 + 60, 0x80000000, 0, index], + }) + )[0]; + } catch (e) { + const parsedError = new RainbowError( + 'gridplus-sdk#fetchAddress failed', + ); + logger.error(parsedError); + throw e; + } + break; + } default: throw new Error('Unknown wallet type'); } @@ -514,7 +548,7 @@ export const importAccountsFromHW = async ( }[], accountsEnabled: number, deviceId: string, - vendor: 'Ledger' | 'Trezor', + vendor: 'Ledger' | 'Trezor' | 'GridPlus', ) => { const address = await walletAction('import_hw', { deviceId, diff --git a/src/entries/popup/pages/hw/addByIndexSheet.tsx b/src/entries/popup/pages/hw/addByIndexSheet.tsx index 01a97e7f52..69673ce9da 100644 --- a/src/entries/popup/pages/hw/addByIndexSheet.tsx +++ b/src/entries/popup/pages/hw/addByIndexSheet.tsx @@ -72,7 +72,7 @@ export const AddByIndexSheet = ({ index?: number; hdPath?: string; }) => void; - vendor: 'Ledger' | 'Trezor'; + vendor: 'Ledger' | 'Trezor' | 'GridPlus'; }) => { const inputRef = useRef(null); const prevShow = usePrevious(show); diff --git a/src/entries/popup/pages/hw/chooseHW.tsx b/src/entries/popup/pages/hw/chooseHW.tsx index 4253148dc5..ff2434609a 100644 --- a/src/entries/popup/pages/hw/chooseHW.tsx +++ b/src/entries/popup/pages/hw/chooseHW.tsx @@ -1,6 +1,7 @@ -import React, { useCallback } from 'react'; +import { useCallback } from 'react'; import { useLocation } from 'react-router-dom'; +import gridPlusLogo from 'static/assets/hw/grid-plus-logo.png'; import ledgerLogo from 'static/assets/hw/ledger-logo.png'; import trezorLogo from 'static/assets/hw/trezor-logo.png'; import { i18n } from '~/core/languages'; @@ -41,6 +42,12 @@ export function ChooseHW() { } }, [isFullScreen, navigate, state]); + const handleGridPlusChoice = useCallback(() => { + navigate(ROUTES.HW_GRIDPLUS, { + state: { direction: state?.direction, navbarIcon: state?.navbarIcon }, + }); + }, [navigate, state]); + return ( } onClick={handleTrezorChoice} subtitle={i18n.t('hw.trezor_support')} /> + + + } + onClick={handleGridPlusChoice} + subtitle={i18n.t('hw.gridplus_support')} + /> diff --git a/src/entries/popup/pages/hw/gridplus.tsx b/src/entries/popup/pages/hw/gridplus.tsx new file mode 100644 index 0000000000..36169b48df --- /dev/null +++ b/src/entries/popup/pages/hw/gridplus.tsx @@ -0,0 +1,75 @@ +import { AnimatePresence } from 'framer-motion'; +import { useState } from 'react'; + +import gridPlusLogo from 'static/assets/hw/grid-plus-logo.png'; +import { Box } from '~/design-system'; + +import { FullScreenContainer } from '../../components/FullScreen/FullScreenContainer'; + +import { AddressChoice } from './gridplus/addressChoice'; +import { PairingSecret } from './gridplus/pairingSecret'; +import { WalletCredentials } from './gridplus/walletCredentials'; + +enum GridplusStep { + WALLET_CREDENTIALS = 'WALLET_CREDENTIALS', + PAIRING_SECRET = 'PAIRING_SECRET', + ADDRESS_CHOICE = 'ADDRESS_CHOICE', +} + +const GridPlusRouting = ({ + step, + setStep, +}: { + step: GridplusStep; + setStep: (step: GridplusStep) => void; +}) => { + switch (step) { + case GridplusStep.WALLET_CREDENTIALS: + return ( + + // If wallet is already trusted, user can skip to Address Choice + result + ? setStep(GridplusStep.ADDRESS_CHOICE) + : setStep(GridplusStep.PAIRING_SECRET) + } + /> + ); + case GridplusStep.PAIRING_SECRET: + return ( + setStep(GridplusStep.ADDRESS_CHOICE)} + /> + ); + case GridplusStep.ADDRESS_CHOICE: + return ; + default: + return null; + } +}; + +export function ConnectGridPlus() { + const [gridplusStep, setGridplusStep] = useState( + GridplusStep.WALLET_CREDENTIALS, + ); + return ( + + + + + + + + + ); +} diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx new file mode 100644 index 0000000000..74c999bd1b --- /dev/null +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -0,0 +1,72 @@ +import { useQuery } from '@tanstack/react-query'; +import gridplus from 'gridplus-sdk'; +import { Navigate, useLocation } from 'react-router-dom'; + +import { getWallets } from '~/core/keychain'; +import { useGridPlusClientStore } from '~/core/state/gridplusClient'; +import { KeychainType } from '~/core/types/keychainTypes'; +import { Spinner } from '~/entries/popup/components/Spinner/Spinner'; +import { HARDWARE_WALLETS } from '~/entries/popup/handlers/walletVariables'; +import { ROUTES } from '~/entries/popup/urls'; + +const useGridPlusAddresses = () => + useQuery({ + queryKey: ['gridplusAddresses', useGridPlusClientStore.getState().client], + queryFn: async () => { + if (process.env.IS_TESTING === 'true') { + return HARDWARE_WALLETS.MOCK_ACCOUNT.accountsToImport.map( + (account) => account.address, + ); + } + + /* + the code below could be removed when we merge + https://github.com/rainbow-me/browser-extension/pull/1435 + as the keychain itself will handle it + */ + + const currentWallets = getWallets(); + + const gridplusAddresses = await gridplus.fetchAddresses(); + + const alreadyAddedOwnedAccounts = (await currentWallets) + .filter((a) => a.type !== KeychainType.ReadOnlyKeychain) + .flatMap((a) => a.accounts); + + // ignore addresses already in the extension + return gridplusAddresses.filter( + (address) => !alreadyAddedOwnedAccounts.includes(address), + ); + }, + staleTime: 0, + cacheTime: 0, + }); + +export const AddressChoice = () => { + const { state } = useLocation(); + + const { data: addresses, isFetching } = useGridPlusAddresses(); + + if (isFetching || !addresses || addresses.length === 0) + return ; + + const accountsToImport = addresses.map((address) => ({ + address, + index: -1, // GridPlus doesn't support add by index, gonna keep it with a negative value to avoid refactoring the whole flow + })); + + return ( + + ); +}; diff --git a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx new file mode 100644 index 0000000000..b719882c44 --- /dev/null +++ b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx @@ -0,0 +1,106 @@ +import { motion } from 'framer-motion'; +import { pair } from 'gridplus-sdk'; +import { FormEvent, useState } from 'react'; + +import { i18n } from '~/core/languages'; +import { Box, Button, Text } from '~/design-system'; +import { Input } from '~/design-system/components/Input/Input'; +import { Spinner } from '~/entries/popup/components/Spinner/Spinner'; + +export type PairingSecretProps = { + onAfterPair?: () => void; +}; + +export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { + const [pairing, setPairing] = useState(false); + const [formState, setFormState] = useState({ + error: false, + }); + const [formData, setFormData] = useState({ + pairingCode: '', + }); + const disabled = pairing || formData.pairingCode.length < 8; + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setPairing(true); + try { + const result = await pair(formData.pairingCode); + if (!result) { + return setFormState({ error: true }); + } + onAfterPair && onAfterPair(); + } finally { + setPairing(false); + } + }; + return ( + + + + {i18n.t('hw.gridplus_check_device')} + + + + {i18n.t('hw.gridplus_pairing_code')} + + + setFormData({ ...formData, pairingCode: e.target.value }) + } + value={formData.pairingCode} + testId="gridplus-pairing-code" + tabIndex={0} + autoFocus + /> + {formState.error && ( + + {i18n.t('hw.gridplus_wrong_code')} + + )} + + + + + ); +}; diff --git a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx new file mode 100644 index 0000000000..964df079c3 --- /dev/null +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -0,0 +1,174 @@ +import { motion } from 'framer-motion'; +import { setup } from 'gridplus-sdk'; +import { uniqueId } from 'lodash'; +import { FormEvent, useEffect, useState } from 'react'; + +import { i18n } from '~/core/languages'; +import { useGridPlusClientStore } from '~/core/state/gridplusClient'; +import { LocalStorage } from '~/core/storage'; +import { Box, Button, Text } from '~/design-system'; +import { Input } from '~/design-system/components/Input/Input'; +import { Spinner } from '~/entries/popup/components/Spinner/Spinner'; +import { + getStoredGridPlusClient, + setStoredGridPlusClient, +} from '~/entries/popup/handlers/gridplus'; + +export type WalletCredentialsProps = { + appName: string; + onAfterSetup?: (result: boolean) => void; +}; + +export const WalletCredentials = ({ + appName, + onAfterSetup, +}: WalletCredentialsProps) => { + const setClient = useGridPlusClientStore((state) => state.setClient); + const [connecting, setConnecting] = useState(false); + const [deviceId, setDeviceId] = useState(''); + const formDataFilled = deviceId.length > 0; // device ids probably have a pattern we could match against + + const disabled = !formDataFilled || connecting; + + const setStoredClient = (storedClient: string | null) => { + if (!storedClient) return; + setStoredGridPlusClient(storedClient); + setClient(storedClient); + }; + + const [isTakingTooLong, setIsTakingTooLong] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setConnecting(true); + const takingTooLongTimeout = setTimeout( + () => setIsTakingTooLong(true), + 5000, + ); + try { + let result: boolean; + if (process.env.IS_TESTING === 'true') { + result = true; + } else { + result = await setup({ + deviceId: deviceId, + password: uniqueId(), + name: appName, + getStoredClient: () => useGridPlusClientStore.getState().client, + setStoredClient: setStoredClient, + }); + } + await LocalStorage.set('gridPlusDeviceId', deviceId); + onAfterSetup && onAfterSetup(result); + } finally { + clearTimeout(takingTooLongTimeout); + setIsTakingTooLong(false); + setConnecting(false); + } + }; + + useEffect(() => { + const checkPersistedClient = async () => { + const gridPlusClient = await getStoredGridPlusClient(); + if (gridPlusClient) { + const result = await setup({ + getStoredClient: () => gridPlusClient, + setStoredClient: setStoredGridPlusClient, + name: appName, + }); + onAfterSetup && onAfterSetup(result); + } + }; + checkPersistedClient(); + }, [appName, onAfterSetup]); + + return ( + + + + {i18n.t('hw.connect_gridplus_title')} + + + + {i18n.t('hw.connect_gridplus_description')} + + + + + + {i18n.t('hw.gridplus_device_id')} + + + {i18n.t('hw.gridplus_device_id_description')} + + + setDeviceId(e.target.value)} + value={deviceId} + testId="gridplus-deviceid" + aria-label="username" + tabIndex={0} + /> + + + + {isTakingTooLong && ( + + {i18n.t('hw.connect_gridplus_taking_too_long')} + + )} + + ); +}; diff --git a/src/entries/popup/pages/hw/walletList/index.tsx b/src/entries/popup/pages/hw/walletList/index.tsx index ca291be73e..ade81fc49a 100644 --- a/src/entries/popup/pages/hw/walletList/index.tsx +++ b/src/entries/popup/pages/hw/walletList/index.tsx @@ -1,5 +1,5 @@ import { Address } from '@wagmi/core'; -import React, { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { i18n } from '~/core/languages'; @@ -33,7 +33,7 @@ import { AddByIndexSheet } from '../addByIndexSheet'; import { AccountIndex } from './AccountIndex'; -type Vendor = 'Ledger' | 'Trezor'; +type Vendor = 'Ledger' | 'Trezor' | 'GridPlus'; const WalletListHW = () => { const [showAddByIndexSheet, setShowAddByIndexSheet] = @@ -44,6 +44,8 @@ const WalletListHW = () => { const [isLoading, setIsLoading] = useState(false); const { setCurrentAddress } = useCurrentAddressStore(); + const { supportsAddByIndex = true } = state; + const [accountsToImport, setAccountsToImport] = useState< { address: Address; index: number; hdPath?: string }[] >(state.accountsToImport); @@ -241,36 +243,38 @@ const WalletListHW = () => { })} - - - { - setShowAddByIndexSheet(true); - }} - > - + + { + setShowAddByIndexSheet(true); + }} > - - - {i18n.t('hw.add_by_index')} - - - - - + + + {i18n.t('hw.add_by_index')} + + + + + + )} )} @@ -330,9 +334,13 @@ const WalletListHW = () => { color="label" address={address as Address} /> - - - + {index !== -1 && ( + + + + )} {!walletsSummaryIsLoading ? ( @@ -375,6 +383,7 @@ const WalletListHW = () => { {newDevice && + supportsAddByIndex && Object.values(walletsSummary).length <= 6 && (