From 06bb15bf1ade0549697eca80e2ccf708eabe67e6 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Tue, 21 Nov 2023 14:57:27 +0100 Subject: [PATCH 01/30] feat(hw): add gridplus connect option --- src/entries/popup/pages/hw/chooseHW.tsx | 17 ++++++++++++++++- static/assets/hw/grid-plus-logo.png | Bin 0 -> 5233 bytes static/json/languages/en_US.json | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 static/assets/hw/grid-plus-logo.png diff --git a/src/entries/popup/pages/hw/chooseHW.tsx b/src/entries/popup/pages/hw/chooseHW.tsx index 4253148dc5..8541c6601a 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 { 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'; @@ -64,13 +65,27 @@ export function ChooseHW() { } onClick={handleTrezorChoice} subtitle={i18n.t('hw.trezor_support')} /> + + + } + onClick={() => console.log('Start GridPlus pairing')} + subtitle={i18n.t('hw.gridplus_support')} + /> diff --git a/static/assets/hw/grid-plus-logo.png b/static/assets/hw/grid-plus-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..75f7cf74277c6445a84deb974fe6c0ae50134406 GIT binary patch literal 5233 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 NfUyA@Rflwb@E=ds4gLTC literal 0 HcmV?d00001 diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index 259b93a24d..f2a1c6c86d 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -1373,6 +1373,7 @@ "choose_title": "Connect a hardware wallet", "ledger_support": "Supports Ledger Nano S, Nano S Plus, Nano X, and Stax devices.", "trezor_support": "Supports Model One or Model T devices.", + "gridplus_support": "Supports Lattice1.", "connect_ledger_title": "Connect your Ledger", "connect_ledger_description": "Connect your Ledger to Rainbow by plugging it into your computer using the USB-C cable.", "unlock_ledger_title": "Unlock Ledger", From 0e91d8dd987943b721e2e50cc37fdb3afb4fcae6 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Wed, 22 Nov 2023 14:40:11 +0100 Subject: [PATCH 02/30] feat(gridplus): add a gridplus connector route --- src/entries/popup/Routes.tsx | 14 ++++ src/entries/popup/handlers/gridplus.ts | 0 src/entries/popup/handlers/wallet.ts | 6 +- src/entries/popup/pages/hw/chooseHW.tsx | 14 +++- src/entries/popup/pages/hw/gridplus.tsx | 67 ++++++++++++++++++ .../popup/pages/hw/walletList/index.tsx | 2 +- src/entries/popup/urls.ts | 1 + static/assets/hw/grid-plus-device.png | Bin 0 -> 98378 bytes static/json/languages/en_US.json | 5 ++ 9 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 src/entries/popup/handlers/gridplus.ts create mode 100644 src/entries/popup/pages/hw/gridplus.tsx create mode 100644 static/assets/hw/grid-plus-device.png diff --git a/src/entries/popup/Routes.tsx b/src/entries/popup/Routes.tsx index b2896ce54e..fb098ba291 100644 --- a/src/entries/popup/Routes.tsx +++ b/src/entries/popup/Routes.tsx @@ -42,6 +42,7 @@ import { ConnectedApps } from './pages/home/ConnectedApps'; import NFTDetails from './pages/home/NFTs/NFTDetails'; 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'; @@ -300,6 +301,19 @@ const ROUTE_DATA = [ ), }, + { + path: ROUTES.HW_GRIDPLUS, + element: ( + + + + ), + }, { path: ROUTES.HW_WALLET_LIST, element: ( diff --git a/src/entries/popup/handlers/gridplus.ts b/src/entries/popup/handlers/gridplus.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/entries/popup/handlers/wallet.ts b/src/entries/popup/handlers/wallet.ts index 8be15f2812..035ccce8a9 100644 --- a/src/entries/popup/handlers/wallet.ts +++ b/src/entries/popup/handlers/wallet.ts @@ -523,6 +523,10 @@ export const connectLedger = async () => { } }; +export const connectGridPlus = async () => { + console.log('>>>START G+ FLOW'); +}; + export const importAccountsFromHW = async ( accountsToImport: { address: string; @@ -531,7 +535,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/chooseHW.tsx b/src/entries/popup/pages/hw/chooseHW.tsx index 8541c6601a..6c317baa9a 100644 --- a/src/entries/popup/pages/hw/chooseHW.tsx +++ b/src/entries/popup/pages/hw/chooseHW.tsx @@ -42,6 +42,18 @@ export function ChooseHW() { } }, [isFullScreen, navigate, state]); + const handleGridPlusChoice = useCallback(() => { + if (!isFullScreen) { + goToNewTab({ + url: POPUP_URL + `#${ROUTES.HW_GRIDPLUS}`, + }); + } else { + navigate(ROUTES.HW_GRIDPLUS, { + state: { direction: state?.direction, navbarIcon: state?.navbarIcon }, + }); + } + }, [isFullScreen, navigate, state]); + return ( } - onClick={() => console.log('Start GridPlus pairing')} + 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..5d0069ff21 --- /dev/null +++ b/src/entries/popup/pages/hw/gridplus.tsx @@ -0,0 +1,67 @@ +import React, { useEffect } from 'react'; + +import gridplusDevice from 'static/assets/hw/grid-plus-device.png'; +import { i18n } from '~/core/languages'; +import { goToNewTab } from '~/core/utils/tabs'; +import { Box, Separator, Text } from '~/design-system'; +import { TextLink } from '~/design-system/components/TextLink/TextLink'; + +import { FullScreenContainer } from '../../components/FullScreen/FullScreenContainer'; +import * as wallet from '../../handlers/wallet'; +import { useRainbowNavigate } from '../../hooks/useRainbowNavigate'; +// import { ROUTES } from '../../urls'; + +export function ConnectGridPlus() { + const navigate = useRainbowNavigate(); + useEffect(() => { + setTimeout(async () => { + await wallet.connectGridPlus(); + // if (!res) alert('error connecting to GridPlus'); + // if (res?.accountsToImport?.length) { + // navigate(ROUTES.HW_WALLET_LIST, { + // state: { + // ...res, + // vendor: 'GridPlus', + // }, + // }); + // } + }, 1500); + }, [navigate]); + + return ( + + + + {i18n.t('hw.connect_gridplus_title')} + + + + {i18n.t('hw.connect_gridplus_description')}{' '} + goToNewTab({ url: 'https://learn.rainbow.me/' })} + > + {i18n.t('hw.learn_more')} + + + + + + + + + + + + ); +} diff --git a/src/entries/popup/pages/hw/walletList/index.tsx b/src/entries/popup/pages/hw/walletList/index.tsx index ca291be73e..12c27318bb 100644 --- a/src/entries/popup/pages/hw/walletList/index.tsx +++ b/src/entries/popup/pages/hw/walletList/index.tsx @@ -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] = diff --git a/src/entries/popup/urls.ts b/src/entries/popup/urls.ts index 7340388385..d57f1b5064 100644 --- a/src/entries/popup/urls.ts +++ b/src/entries/popup/urls.ts @@ -67,6 +67,7 @@ export const ROUTES = { HW_CHOOSE: '/hw/choose', HW_LEDGER: '/hw/ledger', HW_TREZOR: '/hw/trezor', + HW_GRIDPLUS: '/hw/gridplus', HW_WALLET_LIST: '/hw/wallet-list', // hw/walletList/index HW_SUCCESS: '/hw/success', BUY: '/buy', diff --git a/static/assets/hw/grid-plus-device.png b/static/assets/hw/grid-plus-device.png new file mode 100644 index 0000000000000000000000000000000000000000..6fa6fb3cdbc8652c980428664a0de7089230b22d GIT binary patch literal 98378 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 z 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%0DG<@>Yj>N;0Sc2mj;fn^$ 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 literal 0 HcmV?d00001 diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index f2a1c6c86d..e2d0ed6232 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -1386,7 +1386,9 @@ "needs_app_ledger_description": "The Ethereum app needs to be open on your Ledger to proced with the transaction.", "needs_app_ledger_description_2": "Enter your passcode to unlock your Ledger. Once unlocked, open the Ethereum app by pressing both buttons at once.", "connect_trezor_title": "Complete your Trezor set up", + "connect_gridplus_title": "Complete your Lattice1 set up", "connect_trezor_description": "Continue to connect your Trezor to Rainbow through the web interface.", + "connect_gridplus_description": "Continue to connect your Lattice1 to Rainbow through the web interface.", "learn_more": "Learn more.", "connect_wallets_title": "Connect your wallets", "connect_wallets_found": "We’ve found %{count} wallets on your %{vendor} with a balance or activity. Select which to connect.", @@ -1407,8 +1409,11 @@ "add_by_index_title": "Add a wallet by its index", "add_by_index_description": "Select the derivation path and enter the index of the wallet you would like to add.", "waiting_for_trezor_connect": "Waiting for Trezor Connect", + "waiting_for_gridplus_connect": "Waiting for GridPlus Connect", "continue_on_trezor_connect": "Please follow the instructions on the Trezor Connect window", + "continue_on_gridplus_connect": "Please follow the instructions on the GridPlus Connect window", "trezor_success": "Success!", + "gridplus_success": "Success!", "you_can_close_this_window": "You can close this window", "ledger_locked_error": "Please make sure your ledger is unlocked and open the Ethereum app", "check_ledger_disconnected": "Make sure your device is connected, unlocked and the ethereum app is open" From d840b7ec1634f24f473c071b0303114bec5c3874 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Mon, 4 Dec 2023 15:39:55 +0100 Subject: [PATCH 03/30] feat(gridplus): add nested routes for gridplus onboarding --- lavamoat/build-webpack/policy.json | 36 +-- package.json | 5 +- src/entries/popup/index.html | 24 +- src/entries/popup/pages/hw/gridplus.tsx | 105 +++++---- .../popup/pages/hw/gridplus/addressChoice.tsx | 53 +++++ .../popup/pages/hw/gridplus/pairingSecret.tsx | 46 ++++ .../pages/hw/gridplus/walletCredentials.tsx | 81 +++++++ static/manifest.json | 36 +-- yarn.lock | 211 +++++++++++++++++- 9 files changed, 473 insertions(+), 124 deletions(-) create mode 100644 src/entries/popup/pages/hw/gridplus/addressChoice.tsx create mode 100644 src/entries/popup/pages/hw/gridplus/pairingSecret.tsx create mode 100644 src/entries/popup/pages/hw/gridplus/walletCredentials.tsx diff --git a/lavamoat/build-webpack/policy.json b/lavamoat/build-webpack/policy.json index ea39303347..f4ca905f79 100644 --- a/lavamoat/build-webpack/policy.json +++ b/lavamoat/build-webpack/policy.json @@ -808,6 +808,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 @@ -1445,23 +1461,7 @@ }, "native": true, "packages": { - "web-ext>ws>bufferutil>node-gyp-build": true - } - }, - "web-ext>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 } }, "web-ext>ws>utf-8-validate": { @@ -1470,7 +1470,7 @@ }, "native": true, "packages": { - "web-ext>ws>bufferutil>node-gyp-build": true + "gridplus-sdk>secp256k1>node-gyp-build": true } }, "webpack": { diff --git a/package.json b/package.json index 0f485ed075..50ee1a2f73 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,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", "imgix-core-js": "2.3.2", "lodash": "4.17.21", @@ -305,8 +306,10 @@ "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 } } -} \ No newline at end of file +} diff --git a/src/entries/popup/index.html b/src/entries/popup/index.html index ffed3d2728..24f343a7ee 100644 --- a/src/entries/popup/index.html +++ b/src/entries/popup/index.html @@ -1,15 +1,13 @@ - + + + + + Rainbow Wallet + + - - - - Rainbow Wallet - - - - -
- - - \ No newline at end of file + +
+ + diff --git a/src/entries/popup/pages/hw/gridplus.tsx b/src/entries/popup/pages/hw/gridplus.tsx index 5d0069ff21..d43de8a841 100644 --- a/src/entries/popup/pages/hw/gridplus.tsx +++ b/src/entries/popup/pages/hw/gridplus.tsx @@ -1,66 +1,65 @@ -import React, { useEffect } from 'react'; +import React, { useState } from 'react'; -import gridplusDevice from 'static/assets/hw/grid-plus-device.png'; -import { i18n } from '~/core/languages'; -import { goToNewTab } from '~/core/utils/tabs'; -import { Box, Separator, Text } from '~/design-system'; -import { TextLink } from '~/design-system/components/TextLink/TextLink'; +import gridPlusLogo from 'static/assets/hw/grid-plus-logo.png'; +import { Box } from '~/design-system'; import { FullScreenContainer } from '../../components/FullScreen/FullScreenContainer'; -import * as wallet from '../../handlers/wallet'; -import { useRainbowNavigate } from '../../hooks/useRainbowNavigate'; -// import { ROUTES } from '../../urls'; -export function ConnectGridPlus() { - const navigate = useRainbowNavigate(); - useEffect(() => { - setTimeout(async () => { - await wallet.connectGridPlus(); - // if (!res) alert('error connecting to GridPlus'); - // if (res?.accountsToImport?.length) { - // navigate(ROUTES.HW_WALLET_LIST, { - // state: { - // ...res, - // vendor: 'GridPlus', - // }, - // }); - // } - }, 1500); - }, [navigate]); +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 ( + setStep(GridplusStep.PAIRING_SECRET)} + /> + ); + case GridplusStep.PAIRING_SECRET: + return ( + setStep(GridplusStep.ADDRESS_CHOICE)} + /> + ); + case GridplusStep.ADDRESS_CHOICE: + return ( + console.log(addresses)} /> + ); + default: + return null; + } +}; + +export function ConnectGridPlus() { + const [gridplusStep, setGridplusStep] = useState( + GridplusStep.WALLET_CREDENTIALS, + ); return ( - - - {i18n.t('hw.connect_gridplus_title')} - - - - {i18n.t('hw.connect_gridplus_description')}{' '} - goToNewTab({ url: 'https://learn.rainbow.me/' })} - > - {i18n.t('hw.learn_more')} - - - - - - - - + + ); 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..5d9e4ac489 --- /dev/null +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -0,0 +1,53 @@ +import { fetchAddresses } from 'gridplus-sdk'; +import { FormEvent, useEffect, useState } from 'react'; + +import { Box, Button, Text } from '~/design-system'; + +export type AddressesData = { + addresses: string[]; +}; + +export type AddressChoiceProps = { + onSelected: (addressses: AddressesData['addresses']) => void; +}; + +export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { + const [addresses, setAddresses] = useState([]); + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + // onSelected(data.addresses); + onSelected([]); + }; + useEffect(() => { + const fetchWalletAddresses = async () => { + const fetchedAddresses = await fetchAddresses(); + setAddresses(fetchedAddresses); + }; + fetchWalletAddresses(); + }, []); + return ( + + + Choose Addresses + +
    + {addresses.map((address) => ( +
  • + + +
  • + ))} +
+ +
+ ); +}; 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..f95d62c812 --- /dev/null +++ b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx @@ -0,0 +1,46 @@ +import { pair } from 'gridplus-sdk'; +import { FormEvent } from 'react'; + +import { Box, Button, Text } from '~/design-system'; +import { Input } from '~/design-system/components/Input/Input'; + +export type PairingSecretProps = { + onAfterPair?: () => void; +}; + +export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + pair('1234'); + onAfterPair && onAfterPair(); + }; + return ( + + + Check your Lattice1 device for the pairing secret. + + + + Pairing Code + + + {/* {!!errors.pairingCode &&

{errors.pairingCode.message}

} */} +
+ +
+ ); +}; 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..12a99a66e7 --- /dev/null +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -0,0 +1,81 @@ +import { setup } from 'gridplus-sdk'; +import { FormEvent, useEffect } from 'react'; + +import { i18n } from '~/core/languages'; +import { Box, Button, Text } from '~/design-system'; +import { Input } from '~/design-system/components/Input/Input'; + +export type WalletCredentialsProps = { + appName: string; + onAfterSetup?: () => void; +}; + +export const WalletCredentials = ({ + appName, + onAfterSetup, +}: WalletCredentialsProps) => { + const getStoredClient = () => localStorage.getItem('storedClient') || ''; + + const setStoredClient = (storedClient: string | null) => { + if (!storedClient) return; + localStorage.setItem('storedClient', storedClient); + }; + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + // setup({ + // deviceId: data.deviceId, + // password: data.password, + // name: appName, + // getStoredClient, + // setStoredClient, + // }); + onAfterSetup && onAfterSetup(); + }; + useEffect(() => { + if (getStoredClient()) { + setup({ getStoredClient, setStoredClient, name: appName }); + } + }, [appName]); + return ( + + + {i18n.t('hw.connect_gridplus_title')} + + + + Device ID + + + {/* {!!errors.deviceId &&

{errors.deviceId.message}

} */} +
+ + + Password + + + {/* {!!errors.password &&

{errors.password.message}

} */} +
+ +
+ ); +}; diff --git a/static/manifest.json b/static/manifest.json index c74f350367..780b134195 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -18,22 +18,13 @@ "content_scripts": [ { "all_frames": true, - "js": [ - "contentscript.js" - ], - "matches": [ - "http://*/*", - "https://*/*" - ], + "js": ["contentscript.js"], + "matches": ["http://*/*", "https://*/*"], "run_at": "document_start" }, { - "matches": [ - "*://connect.trezor.io/9/popup.html" - ], - "js": [ - "./vendor/trezor-content-script.js" - ] + "matches": ["*://connect.trezor.io/9/popup.html"], + "js": ["./vendor/trezor-content-script.js"] } ], "content_security_policy": { @@ -41,11 +32,7 @@ }, "default_locale": "en_US", "description": "DEV VERSION", - "host_permissions": [ - "http://*/*", - "https://*/*", - "wss://*/*" - ], + "host_permissions": ["http://*/*", "https://*/*", "wss://*/*"], "icons": { "16": "images/icon-16.png", "19": "images/icon-19.png", @@ -71,15 +58,8 @@ "version": "1.2.79", "web_accessible_resources": [ { - "matches": [ - "" - ], - "resources": [ - "inpage.js", - "*.woff2", - "popup.css", - "assets/badges/*.png" - ] + "matches": [""], + "resources": ["inpage.js", "*.woff2", "popup.css", "assets/badges/*.png"] } ], "commands": { @@ -93,4 +73,4 @@ "description": "Open the Rainbow Wallet extension" } } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 4bbeff3858..06ba785d4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -457,6 +457,27 @@ resolved "https://registry.yarnpkg.com/@capsizecss/core/-/core-3.0.0.tgz#81b2fb222bd9716d211e4ddd0dc9cc42f71b4f37" integrity sha512-tJNEWMmhHcU5z6ITAiVNN9z+PCTylybVIJqgX7Ts4zN66fe/W2Fe5UWJCCZIP/5uutsl5fYOaVVHZIjsuTVhBQ== +"@chainsafe/as-sha256@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@chainsafe/as-sha256/-/as-sha256-0.3.1.tgz#3639df0e1435cab03f4d9870cc3ac079e57a6fc9" + integrity sha512-hldFFYuf49ed7DAakWVXSJODuq3pzJEguD8tQ7h+sGkM18vja+OFoJI9krnGmgzyuZC2ETX0NOIcCTy31v2Mtg== + +"@chainsafe/persistent-merkle-tree@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@chainsafe/persistent-merkle-tree/-/persistent-merkle-tree-0.4.2.tgz#4c9ee80cc57cd3be7208d98c40014ad38f36f7ff" + integrity sha512-lLO3ihKPngXLTus/L7WHKaw9PnNJWizlOF1H9NNzHP6Xvh82vzg9F2bzkXhYIFshMZ2gTCEz8tq6STe7r5NDfQ== + dependencies: + "@chainsafe/as-sha256" "^0.3.1" + +"@chainsafe/ssz@0.9.4": + version "0.9.4" + resolved "https://registry.yarnpkg.com/@chainsafe/ssz/-/ssz-0.9.4.tgz#696a8db46d6975b600f8309ad3a12f7c0e310497" + integrity sha512-77Qtg2N1ayqs4Bg/wvnWfg5Bta7iy7IRh8XqXh7oNMeP2HBbBwx8m6yTpA8p0EHItWPEBkgZd5S5/LSlp3GXuQ== + dependencies: + "@chainsafe/as-sha256" "^0.3.1" + "@chainsafe/persistent-merkle-tree" "^0.4.2" + case "^1.6.3" + "@coinbase/wallet-sdk@^3.6.6": version "3.7.1" resolved "https://registry.yarnpkg.com/@coinbase/wallet-sdk/-/wallet-sdk-3.7.1.tgz#44b3b7a925ff5cc974e4cbf7a44199ffdcf03541" @@ -702,7 +723,15 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.48.0.tgz#642633964e217905436033a2bd08bf322849b7fb" integrity sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw== -"@ethereumjs/common@^3.2.0": +"@ethereumjs/common@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@ethereumjs/common/-/common-3.1.1.tgz#6f754c8933727ad781f63ca3929caab542fe184e" + integrity sha512-iEl4gQtcrj2udNhEizs04z7WA15ez1QoXL0XzaCyaNgwRyXezIg1DnfNeZUUpJnkrOF/0rYXyq2UFSLxt1NPQg== + dependencies: + "@ethereumjs/util" "^8.0.5" + crc-32 "^1.2.0" + +"@ethereumjs/common@^3.1.1", "@ethereumjs/common@^3.2.0": version "3.2.0" resolved "https://registry.yarnpkg.com/@ethereumjs/common/-/common-3.2.0.tgz#b71df25845caf5456449163012074a55f048e0a0" integrity sha512-pksvzI0VyLgmuEF2FA/JR/4/y6hcPq8OUail3/AvycBaW1d5VSauOZzqGvJ3RTmR4MU35lWE8KseKOsEhrFRBA== @@ -728,6 +757,18 @@ resolved "https://registry.yarnpkg.com/@ethereumjs/rlp/-/rlp-5.0.0.tgz#dd81b32b2237bc32fb1b54534f8ff246a6c89d9b" integrity sha512-WuS1l7GJmB0n0HsXLozCoEFc9IwYgf3l0gCkKVYgR67puVF1O4OpEaN0hWmm1c+iHUHFCKt1hJrvy5toLg+6ag== +"@ethereumjs/tx@4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@ethereumjs/tx/-/tx-4.1.1.tgz#d1b5bf2c4fd3618f2f333b66e262848530d4686a" + integrity sha512-QDj7nuROfoeyK83RObMA0XCZ+LUDdneNkSCIekO498uEKTY25FxI4Whduc/6j0wdd4IqpQvkq+/7vxSULjGIBQ== + dependencies: + "@chainsafe/ssz" "0.9.4" + "@ethereumjs/common" "^3.1.1" + "@ethereumjs/rlp" "^4.0.1" + "@ethereumjs/util" "^8.0.5" + "@ethersproject/providers" "^5.7.2" + ethereum-cryptography "^1.1.2" + "@ethereumjs/tx@5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@ethereumjs/tx/-/tx-5.0.0.tgz#975f25a67ee35bee572ece1f99cd84c45f661eee" @@ -756,7 +797,7 @@ "@ethereumjs/rlp" "^5.0.0" ethereum-cryptography "^2.1.2" -"@ethereumjs/util@^8.1.0": +"@ethereumjs/util@^8.0.5", "@ethereumjs/util@^8.1.0": version "8.1.0" resolved "https://registry.yarnpkg.com/@ethereumjs/util/-/util-8.1.0.tgz#299df97fb6b034e0577ce9f94c7d9d1004409ed4" integrity sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA== @@ -982,7 +1023,7 @@ bech32 "1.1.4" ws "7.4.6" -"@ethersproject/providers@5.7.2": +"@ethersproject/providers@5.7.2", "@ethersproject/providers@^5.7.2": version "5.7.2" resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.7.2.tgz#f8b1a4f275d7ce58cf0a2eec222269a08beb18cb" integrity sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg== @@ -2407,6 +2448,11 @@ dependencies: "@noble/hashes" "1.3.2" +"@noble/hashes@1.2.0", "@noble/hashes@~1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.2.0.tgz#a3150eeb09cc7ab207ebf6d7b9ad311a9bdbed12" + integrity sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ== + "@noble/hashes@1.3.1", "@noble/hashes@^1.3.0": version "1.3.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" @@ -2417,6 +2463,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== +"@noble/secp256k1@1.7.1", "@noble/secp256k1@~1.7.0": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c" + integrity sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -3298,6 +3349,15 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.3.tgz#8584115565228290a6c6c4961973e0903bb3df2f" integrity sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q== +"@scure/bip32@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.1.5.tgz#d2ccae16dcc2e75bc1d75f5ef3c66a338d1ba300" + integrity sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw== + dependencies: + "@noble/hashes" "~1.2.0" + "@noble/secp256k1" "~1.7.0" + "@scure/base" "~1.1.0" + "@scure/bip32@1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.1.tgz#7248aea723667f98160f593d621c47e208ccbb10" @@ -3316,7 +3376,7 @@ "@noble/hashes" "~1.3.2" "@scure/base" "~1.1.2" -"@scure/bip39@1.2.1": +"@scure/bip39@1.1.1", "@scure/bip39@1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.1.tgz#5cee8978656b272a917b7871c981e0541ad6ac2a" integrity sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg== @@ -4104,6 +4164,11 @@ resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.11.4.tgz#62e393f8bc4bd8a06154d110c7d042a93751def3" integrity sha512-uO4CD2ELOjw8tasUrAhvnn2W4A0ZECOvMjCivJr4gA9pGgjv+qxKWY9GLTMVEK8ej85BxQOocUyE7hImmSQYcg== +"@types/uuid@^9.0.0": + version "9.0.7" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.7.tgz#b14cebc75455eeeb160d5fe23c2fcc0c64f724d8" + integrity sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g== + "@types/validator@13.7.12": version "13.7.12" resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.7.12.tgz#a285379b432cc8d103b69d223cbb159a253cf2f7" @@ -5221,7 +5286,7 @@ aes-js@3.0.0: resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.0.0.tgz#e21df10ad6c2053295bcbb8dab40b09dbea87e4d" integrity sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw== -aes-js@^3.1.2: +aes-js@^3.1.1, aes-js@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.1.2.tgz#db9aabde85d5caabbfc0d4f2a4446960f627146a" integrity sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ== @@ -5746,6 +5811,11 @@ bech32@1.1.4: resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.4.tgz#e38c9f37bf179b8eb16ae3a772b40c356d4832e9" integrity sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ== +bech32@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bech32/-/bech32-2.0.0.tgz#078d3686535075c8c79709f054b1b226a133b355" + integrity sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg== + big-integer@^1.6.17, big-integer@^1.6.44: version "1.6.51" resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" @@ -5773,7 +5843,7 @@ bignumber.js@9.0.1: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5" integrity sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA== -bignumber.js@^9.1.1, bignumber.js@^9.1.2: +bignumber.js@^9.0.0, bignumber.js@^9.0.1, bignumber.js@^9.1.1, bignumber.js@^9.1.2: version "9.1.2" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== @@ -5813,6 +5883,13 @@ bindings@^1.3.0, bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" +bitwise@^2.0.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/bitwise/-/bitwise-2.2.0.tgz#9d42b63c7f9312d4d6e98b5a6ae8199a092f26b1" + integrity sha512-KvoqwOWtM3mWFmE0Msxvz2D2j1c3EFV0TUSqYj06JG0685JjC1K1n10GE9Q0ArpkbvOZqN33Gxt5XWhI2YvTIA== + dependencies: + typescript "^5.2.2" + bl@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -5837,7 +5914,7 @@ bmp-js@^0.1.0: resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233" integrity sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw== -bn.js@5.2.1, bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9, bn.js@^5.0.0, bn.js@^5.1.1, bn.js@^5.2.0, bn.js@^5.2.1: +bn.js@5.2.1, bn.js@>4.0.0, bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9, bn.js@^5.0.0, bn.js@^5.1.1, bn.js@^5.2.0, bn.js@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== @@ -5847,6 +5924,19 @@ boolbase@^1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== +borc@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/borc/-/borc-2.1.2.tgz#6ce75e7da5ce711b963755117dd1b187f6f8cf19" + integrity sha512-Sy9eoUi4OiKzq7VovMn246iTo17kzuyHJKomCfpWMlI6RpfN1gk95w7d7gH264nApVLg0HZfcpz62/g4VH1Y4w== + dependencies: + bignumber.js "^9.0.0" + buffer "^5.5.0" + commander "^2.15.0" + ieee754 "^1.1.13" + iso-url "~0.4.7" + json-text-sequence "~0.1.0" + readable-stream "^3.6.0" + borsh@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/borsh/-/borsh-0.7.0.tgz#6e9560d719d86d90dc589bca60ffc8a6c51fec2a" @@ -6048,6 +6138,15 @@ bs58@^4.0.0, bs58@^4.0.1: dependencies: base-x "^3.0.2" +bs58check@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" + integrity sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA== + dependencies: + bs58 "^4.0.0" + create-hash "^1.1.0" + safe-buffer "^5.1.2" + bser@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" @@ -6093,7 +6192,7 @@ buffer@6.0.3, buffer@^6.0.3, buffer@~6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -buffer@^5.2.0, buffer@^5.5.0: +buffer@^5.2.0, buffer@^5.5.0, buffer@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -6230,6 +6329,11 @@ caniuse-lite@^1.0.30001400: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz#30f67d55a865da43e0aeec003f073ea8764d5d7c" integrity sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA== +case@^1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/case/-/case-1.6.3.tgz#0a4386e3e9825351ca2e6216c60467ff5f1ea1c9" + integrity sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ== + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -6618,7 +6722,7 @@ commander@^10.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== -commander@^2.20.0, commander@^2.20.3, commander@^2.6.0: +commander@^2.15.0, commander@^2.20.0, commander@^2.20.3, commander@^2.6.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -7143,6 +7247,11 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== +delimit-stream@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/delimit-stream/-/delimit-stream-0.1.0.tgz#9b8319477c0e5f8aeb3ce357ae305fc25ea1cd2b" + integrity sha512-a02fiQ7poS5CnjiJBAsjGLPp5EwVoGHNeu9sziBd9huppRfsAFIpv5zNLv0V1gbop53ilngAf5Kf331AwcoRBQ== + depd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -7422,7 +7531,7 @@ electron-to-chromium@^1.4.251: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.267.tgz#77d7de2b92806e3e1df8882a982aece815d6b6ea" integrity sha512-ik4QnU3vFRsVgwt0vsn7og28++2cGnsdgqYagaE3ur1f3wj5AzmWu+1k3//SOc6CwkP2xfu46PNfVP6X+SRepg== -elliptic@6.5.4, elliptic@^6.5.3, elliptic@^6.5.4: +elliptic@6.5.4, elliptic@^6.5.2, elliptic@^6.5.3, elliptic@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== @@ -8193,6 +8302,15 @@ eth-block-tracker@6.1.0: json-rpc-random-id "^1.0.1" pify "^3.0.0" +eth-eip712-util-browser@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/eth-eip712-util-browser/-/eth-eip712-util-browser-0.0.3.tgz#334143d76d0a502b456e2ee5f1ce20f678a39fd3" + integrity sha512-RUXQ6Hjl0wEjm/ObWgYKjzMfO1segqcPFGnMPtBkkwGaHGbXXh6WFAn5vZfReK9WWujs35uIW2+kgJmh3FXtww== + dependencies: + bn.js ">4.0.0" + buffer "^6.0.3" + js-sha3 "^0.8.0" + eth-json-rpc-filters@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/eth-json-rpc-filters/-/eth-json-rpc-filters-5.1.0.tgz#f0c2aeaec2a45e2dc6ca1b9843d8e85447821427" @@ -8236,6 +8354,16 @@ ethereum-cryptography@2.1.2, ethereum-cryptography@^2.0.0, ethereum-cryptography "@scure/bip32" "1.3.1" "@scure/bip39" "1.2.1" +ethereum-cryptography@^1.1.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-1.2.0.tgz#5ccfa183e85fdaf9f9b299a79430c044268c9b3a" + integrity sha512-6yFQC9b5ug6/17CQpCyE3k9eKBMdhyVjzUy1WkiuY/E4vj/SXDBbCw8QEIaXqf0Mf2SnY6RmpDcwlUmBSS0EJw== + dependencies: + "@noble/hashes" "1.2.0" + "@noble/secp256k1" "1.7.1" + "@scure/bip32" "1.1.5" + "@scure/bip39" "1.1.1" + ethers@5.7.2, ethers@^5.0.0, ethers@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" @@ -9317,6 +9445,31 @@ graphql@16.8.1, "graphql@^15.0.0 || ^16.0.0": resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07" integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw== +gridplus-sdk@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/gridplus-sdk/-/gridplus-sdk-2.5.2.tgz#ac26967859f579d8c5877b6d62f2c4056b783ea0" + integrity sha512-Gf8VP3HQ0lIiwC4aUUNcetgwWFQ6z9n74IemG5L/ag8CX4IIDX9dsTmB0AcU4bfHDc6Fc/ik/9AJQSRh1cz1Nw== + dependencies: + "@ethereumjs/common" "3.1.1" + "@ethereumjs/tx" "4.1.1" + "@ethersproject/abi" "^5.5.0" + "@types/uuid" "^9.0.0" + aes-js "^3.1.1" + bech32 "^2.0.0" + bignumber.js "^9.0.1" + bitwise "^2.0.4" + borc "^2.1.2" + bs58check "^2.1.2" + buffer "^5.6.0" + crc-32 "^1.2.0" + elliptic "6.5.4" + eth-eip712-util-browser "^0.0.3" + hash.js "^1.1.7" + js-sha3 "^0.8.0" + rlp "^3.0.0" + secp256k1 "4.0.2" + uuid "^9.0.0" + growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -10312,6 +10465,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +iso-url@~0.4.7: + version "0.4.7" + resolved "https://registry.yarnpkg.com/iso-url/-/iso-url-0.4.7.tgz#de7e48120dae46921079fe78f325ac9e9217a385" + integrity sha512-27fFRDnPAMnHGLq36bWTpKET+eiXct3ENlCcdcMdk+mjXrb2kw3mhBUg1B7ewAC0kVzlOPhADzQgz1SE6Tglog== + isobject@^3.0.0, isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" @@ -10811,7 +10969,7 @@ js-sdsl@^4.1.4: resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.4.tgz#78793c90f80e8430b7d8dc94515b6c77d98a26a6" integrity sha512-Y2/yD55y5jteOAmY50JbUZYwk3CP3wnLPEZnlR1w9oKhITrBEtAxwuWKebFf8hMrPMgbYwFoWK/lH2sBkErELw== -js-sha3@0.8.0: +js-sha3@0.8.0, js-sha3@^0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== @@ -10920,6 +11078,13 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== +json-text-sequence@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/json-text-sequence/-/json-text-sequence-0.1.1.tgz#a72f217dc4afc4629fff5feb304dc1bd51a2f3d2" + integrity sha512-L3mEegEWHRekSHjc7+sc8eJhba9Clq1PZ8kMkzf8OxElhXc8O4TS5MwcVlj9aEbm5dr81N90WHC5nAz3UO971w== + dependencies: + delimit-stream "0.1.0" + json5@2.2.2, json5@^1.0.1, json5@^1.0.2, json5@^2.1.2, json5@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.2.tgz#64471c5bdcc564c18f7c1d4df2e2297f2457c5ab" @@ -13799,6 +13964,11 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" +rlp@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/rlp/-/rlp-3.0.0.tgz#5a60725ca4314a3a165feecca1836e4f2c1e2343" + integrity sha512-PD6U2PGk6Vq2spfgiWZdomLvRGDreBLxi5jv5M8EpRo3pU6VEm31KO+HFxE18Q3vgqfDrQ9pZA3FP95rkijNKw== + rollup@^3.27.1: version "3.29.3" resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.3.tgz#97769774ccaa6a3059083d4680fcabd8ead01289" @@ -13953,6 +14123,15 @@ scrypt-js@3.0.1: resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312" integrity sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA== +secp256k1@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.2.tgz#15dd57d0f0b9fdb54ac1fa1694f40e5e9a54f4a1" + integrity sha512-UDar4sKvWAksIlfX3xIaQReADn+WFnHvbVujpcbr+9Sf/69odMwy2MUsz5CKLQgX9nsIyrjuxL2imVyoNHa3fg== + dependencies: + elliptic "^6.5.2" + node-addon-api "^2.0.0" + node-gyp-build "^4.2.0" + selenium-webdriver@4.10.0: version "4.10.0" resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.10.0.tgz#0508cdfbb5ad8470d8fd19db1a69d3e87f474b79" @@ -15134,6 +15313,11 @@ typescript@5.2.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +typescript@^5.2.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" + integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== + ufo@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.1.2.tgz#d0d9e0fa09dece0c31ffd57bd363f030a35cfe76" @@ -15403,6 +15587,11 @@ uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + v8-to-istanbul@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz#b6f994b0b5d4ef255e17a0d17dc444a9f5132fa4" From a9969b07f432310ad10fc4d0bad1f1801113cf82 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Mon, 4 Dec 2023 16:03:54 +0100 Subject: [PATCH 04/30] feat(gridplus): connect gridplus routes --- src/entries/popup/pages/hw/gridplus.tsx | 31 ++++++++++++++++--- .../popup/pages/hw/gridplus/addressChoice.tsx | 3 +- .../popup/pages/hw/gridplus/pairingSecret.tsx | 3 +- .../pages/hw/gridplus/walletCredentials.tsx | 3 +- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/entries/popup/pages/hw/gridplus.tsx b/src/entries/popup/pages/hw/gridplus.tsx index d43de8a841..27dc327e7b 100644 --- a/src/entries/popup/pages/hw/gridplus.tsx +++ b/src/entries/popup/pages/hw/gridplus.tsx @@ -1,9 +1,13 @@ +import { AnimatePresence } from 'framer-motion'; import React, { useState } from 'react'; +import { useLocation } from 'react-router-dom'; import gridPlusLogo from 'static/assets/hw/grid-plus-logo.png'; import { Box } from '~/design-system'; import { FullScreenContainer } from '../../components/FullScreen/FullScreenContainer'; +import { useRainbowNavigate } from '../../hooks/useRainbowNavigate'; +import { ROUTES } from '../../urls'; import { AddressChoice } from './gridplus/addressChoice'; import { PairingSecret } from './gridplus/pairingSecret'; @@ -18,9 +22,11 @@ enum GridplusStep { const GridPlusRouting = ({ step, setStep, + onFinish, }: { step: GridplusStep; setStep: (step: GridplusStep) => void; + onFinish: (addresses: string[]) => void; }) => { switch (step) { case GridplusStep.WALLET_CREDENTIALS: @@ -37,18 +43,29 @@ const GridPlusRouting = ({ /> ); case GridplusStep.ADDRESS_CHOICE: - return ( - console.log(addresses)} /> - ); + return ; default: return null; } }; export function ConnectGridPlus() { + const navigate = useRainbowNavigate(); + const { state } = useLocation(); const [gridplusStep, setGridplusStep] = useState( GridplusStep.WALLET_CREDENTIALS, ); + const onFinish = (addresses: string[]) => { + console.log('>>>ADDRS', addresses); + navigate(ROUTES.HW_WALLET_LIST, { + state: { + // ...res, + vendor: 'GridPlus', + direction: state?.direction, + navbarIcon: state?.navbarIcon, + }, + }); + }; return ( - + + + ); diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index 5d9e4ac489..6c39e4a8de 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -1,3 +1,4 @@ +import { motion } from 'framer-motion'; import { fetchAddresses } from 'gridplus-sdk'; import { FormEvent, useEffect, useState } from 'react'; @@ -27,7 +28,7 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { }, []); return ( { }; return ( Date: Thu, 14 Dec 2023 20:57:22 +0100 Subject: [PATCH 05/30] feat(gridplus): unstub onboarding --- .../popup/pages/hw/gridplus/addressChoice.tsx | 1 + .../popup/pages/hw/gridplus/pairingSecret.tsx | 15 ++++++--- .../pages/hw/gridplus/walletCredentials.tsx | 33 ++++++++++++------- static/allowlist.json | 3 +- static/manifest.json | 2 +- 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index 6c39e4a8de..897aac5e6e 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -26,6 +26,7 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { }; fetchWalletAddresses(); }, []); + console.log('>>>ADDRS', addresses); return ( { - const onSubmit = (event: FormEvent) => { + const [formData, setFormData] = useState({ + pairingCode: '', + }); + const onSubmit = async (event: FormEvent) => { event.preventDefault(); - pair('1234'); + const result = await pair(formData.pairingCode); + console.log('>>>RES', result); onAfterPair && onAfterPair(); }; return ( @@ -36,8 +40,11 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { height="40px" variant="bordered" placeholder="Pairing Code" + onChange={(e) => + setFormData({ ...formData, pairingCode: e.target.value }) + } + value={formData.pairingCode} /> - {/* {!!errors.pairingCode &&

{errors.pairingCode.message}

} */}
From 8ff215d39d27a766812d9aa19987336246fe88fb Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Fri, 5 Jan 2024 17:16:46 +0100 Subject: [PATCH 08/30] feat(gridplus): add signTransactionFromGridPlus and sendTransactionFromGridPlus --- package.json | 3 + src/core/types/keychainTypes.ts | 2 +- src/entries/popup/handlers/gridplus.ts | 120 ++++++++++++++++++++++++ src/entries/popup/handlers/wallet.ts | 3 + static/json/languages/en_US.json | 3 +- yarn.lock | 122 +++++-------------------- 6 files changed, 150 insertions(+), 103 deletions(-) diff --git a/package.json b/package.json index 3341753a3e..5ec82608c7 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ }, "dependencies": { "@capsizecss/core": "3.0.0", + "@ethereumjs/common": "4.1.0", "@ethereumjs/tx": "5.0.0", "@ethereumjs/util": "9.0.0", "@ethersproject/abstract-signer": "5.7.0", @@ -230,6 +231,8 @@ "worker-loader": "3.0.8" }, "resolutions": { + "@ethereumjs/common": "4.1.0", + "@ethereumjs/tx": "5.0.0", "bn.js": "5.2.1", "@scure/bip39": "1.2.1", "protobufjs": "7.2.4", 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/entries/popup/handlers/gridplus.ts b/src/entries/popup/handlers/gridplus.ts index e69de29bb2..48a90c2297 100644 --- a/src/entries/popup/handlers/gridplus.ts +++ b/src/entries/popup/handlers/gridplus.ts @@ -0,0 +1,120 @@ +import { Chain, Common, Hardfork } from '@ethereumjs/common'; +import { TransactionFactory, TypedTxData } from '@ethereumjs/tx'; +import { + TransactionRequest, + TransactionResponse, +} from '@ethersproject/abstract-provider'; +import { BigNumber } from '@ethersproject/bignumber'; +import { + UnsignedTransaction, + parse, + serialize, +} from '@ethersproject/transactions'; +import { ChainId } from '@rainbow-me/swaps'; +import { getProvider } from '@wagmi/core'; +import { sign as gridPlusSign, setup } from 'gridplus-sdk'; + +import { LEGACY_CHAINS_FOR_HW } from '~/core/references'; + +const getStoredClient = () => localStorage.getItem('storedClient') || ''; + +const setStoredClient = (storedClient: string | null) => { + if (!storedClient) return; + localStorage.setItem('storedClient', storedClient); +}; + +export async function signTransactionFromGridPlus( + transaction: TransactionRequest, +) { + try { + await setup({ getStoredClient, setStoredClient, name: 'Rainbow' }); + const { from: address } = transaction; + const baseTx: UnsignedTransaction = { + chainId: transaction.chainId || undefined, + data: transaction.data || undefined, + gasLimit: transaction.gasLimit + ? BigNumber.from(transaction.gasLimit).toHexString() + : undefined, + nonce: transaction.nonce + ? BigNumber.from(transaction.nonce).toNumber() + : undefined, + to: transaction.to || undefined, + value: transaction.value + ? BigNumber.from(transaction.value).toHexString() + : undefined, + }; + + let forceLegacy = false; + // HW doesn't support type 2 for these networks yet + if (LEGACY_CHAINS_FOR_HW.includes(transaction.chainId as ChainId)) { + forceLegacy = true; + } + + if (transaction.gasPrice) { + baseTx.gasPrice = transaction.gasPrice; + } else if (!forceLegacy) { + baseTx.maxFeePerGas = transaction.maxFeePerGas || undefined; + baseTx.maxPriorityFeePerGas = + transaction.maxPriorityFeePerGas || undefined; + baseTx.type = 2; + } else { + baseTx.gasPrice = transaction.maxFeePerGas || undefined; + } + + const common = new Common({ + chain: Chain.Mainnet, + hardfork: Hardfork.London, + }); + + const txPayload = TransactionFactory.fromTxData(baseTx as TypedTxData, { + common, + }); + + const response = await gridPlusSign(txPayload.getMessageToSign() as Buffer); + + const r = '0x' + response.sig.r.toString('hex'); + const s = '0x' + response.sig.s.toString('hex'); + const v = BigNumber.from('0x' + response.sig.v.toString('hex')).toNumber(); + + if (response.pubkey) { + if (baseTx.gasLimit) { + baseTx.type = 2; + } + const serializedTransaction = serialize(baseTx, { + r, + s, + v, + }); + + const parsedTx = parse(serializedTransaction); + if (parsedTx.from?.toLowerCase() !== address?.toLowerCase()) { + console.log('>>>PARSED_TX', parsedTx); + throw new Error('Transaction was not signed by the right address'); + } + + return serializedTransaction; + } else { + console.log('gridplus error', JSON.stringify(response, null, 2), baseTx); + 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) { + console.log('gridplus error', e); + 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); +} diff --git a/src/entries/popup/handlers/wallet.ts b/src/entries/popup/handlers/wallet.ts index 6563e514b9..b9ef49fc15 100644 --- a/src/entries/popup/handlers/wallet.ts +++ b/src/entries/popup/handlers/wallet.ts @@ -33,6 +33,7 @@ import { RainbowError, logger } from '~/logger'; import { PathOptions } from '../pages/hw/addByIndexSheet'; +import { sendTransactionFromGridPlus } from './gridplus'; import { sendTransactionFromLedger, signMessageByTypeFromLedger, @@ -153,6 +154,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'); } diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index 30b103c18b..fe960f27cf 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -23,6 +23,7 @@ "watching": "Watching", "ledger": "Ledger", "trezor": "Trezor", + "gridplus": "GridPlus", "add_another_wallet": "Add another wallet", "connect_hardware_wallet": "Add from your hardware wallet", "search_placeholder": "Search wallets", @@ -1618,4 +1619,4 @@ "address": "Address", "not_found": "We couldn't find this asset on the selected network. Please choose the right network below" } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 3292373074..3a3613428e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -457,27 +457,6 @@ resolved "https://registry.yarnpkg.com/@capsizecss/core/-/core-3.0.0.tgz#81b2fb222bd9716d211e4ddd0dc9cc42f71b4f37" integrity sha512-tJNEWMmhHcU5z6ITAiVNN9z+PCTylybVIJqgX7Ts4zN66fe/W2Fe5UWJCCZIP/5uutsl5fYOaVVHZIjsuTVhBQ== -"@chainsafe/as-sha256@^0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@chainsafe/as-sha256/-/as-sha256-0.3.1.tgz#3639df0e1435cab03f4d9870cc3ac079e57a6fc9" - integrity sha512-hldFFYuf49ed7DAakWVXSJODuq3pzJEguD8tQ7h+sGkM18vja+OFoJI9krnGmgzyuZC2ETX0NOIcCTy31v2Mtg== - -"@chainsafe/persistent-merkle-tree@^0.4.2": - version "0.4.2" - resolved "https://registry.yarnpkg.com/@chainsafe/persistent-merkle-tree/-/persistent-merkle-tree-0.4.2.tgz#4c9ee80cc57cd3be7208d98c40014ad38f36f7ff" - integrity sha512-lLO3ihKPngXLTus/L7WHKaw9PnNJWizlOF1H9NNzHP6Xvh82vzg9F2bzkXhYIFshMZ2gTCEz8tq6STe7r5NDfQ== - dependencies: - "@chainsafe/as-sha256" "^0.3.1" - -"@chainsafe/ssz@0.9.4": - version "0.9.4" - resolved "https://registry.yarnpkg.com/@chainsafe/ssz/-/ssz-0.9.4.tgz#696a8db46d6975b600f8309ad3a12f7c0e310497" - integrity sha512-77Qtg2N1ayqs4Bg/wvnWfg5Bta7iy7IRh8XqXh7oNMeP2HBbBwx8m6yTpA8p0EHItWPEBkgZd5S5/LSlp3GXuQ== - dependencies: - "@chainsafe/as-sha256" "^0.3.1" - "@chainsafe/persistent-merkle-tree" "^0.4.2" - case "^1.6.3" - "@coinbase/wallet-sdk@^3.6.6": version "3.7.1" resolved "https://registry.yarnpkg.com/@coinbase/wallet-sdk/-/wallet-sdk-3.7.1.tgz#44b3b7a925ff5cc974e4cbf7a44199ffdcf03541" @@ -723,28 +702,12 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.48.0.tgz#642633964e217905436033a2bd08bf322849b7fb" integrity sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw== -"@ethereumjs/common@3.1.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@ethereumjs/common/-/common-3.1.1.tgz#6f754c8933727ad781f63ca3929caab542fe184e" - integrity sha512-iEl4gQtcrj2udNhEizs04z7WA15ez1QoXL0XzaCyaNgwRyXezIg1DnfNeZUUpJnkrOF/0rYXyq2UFSLxt1NPQg== - dependencies: - "@ethereumjs/util" "^8.0.5" - crc-32 "^1.2.0" - -"@ethereumjs/common@^3.1.1", "@ethereumjs/common@^3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@ethereumjs/common/-/common-3.2.0.tgz#b71df25845caf5456449163012074a55f048e0a0" - integrity sha512-pksvzI0VyLgmuEF2FA/JR/4/y6hcPq8OUail3/AvycBaW1d5VSauOZzqGvJ3RTmR4MU35lWE8KseKOsEhrFRBA== - dependencies: - "@ethereumjs/util" "^8.1.0" - crc-32 "^1.2.0" - -"@ethereumjs/common@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@ethereumjs/common/-/common-4.0.0.tgz#da99cdc822041da02753867d4773683f91e5854f" - integrity sha512-eVa0/nC15mpotD8HOq6jB883SCWUkLjibr2jLPmPrx4FfmewXqFeh4drgR2sHjq3qWKxpCLK+5qsSJgtXwIzJQ== +"@ethereumjs/common@3.1.1", "@ethereumjs/common@4.1.0", "@ethereumjs/common@^4.0.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ethereumjs/common/-/common-4.1.0.tgz#0a959320a69bd2e3b194144b29c61b63bd6e2f6a" + integrity sha512-XWdQvUjlQHVwh4uGEPFKHpsic69GOsMXEhlHrggS5ju/+2zAmmlz6B25TkCCymeElC9DUp13tH5Tc25Iuvtlcg== dependencies: - "@ethereumjs/util" "^9.0.0" + "@ethereumjs/util" "^9.0.1" crc "^4.3.2" "@ethereumjs/rlp@^4.0.1": @@ -757,19 +720,12 @@ resolved "https://registry.yarnpkg.com/@ethereumjs/rlp/-/rlp-5.0.0.tgz#dd81b32b2237bc32fb1b54534f8ff246a6c89d9b" integrity sha512-WuS1l7GJmB0n0HsXLozCoEFc9IwYgf3l0gCkKVYgR67puVF1O4OpEaN0hWmm1c+iHUHFCKt1hJrvy5toLg+6ag== -"@ethereumjs/tx@4.1.1": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@ethereumjs/tx/-/tx-4.1.1.tgz#d1b5bf2c4fd3618f2f333b66e262848530d4686a" - integrity sha512-QDj7nuROfoeyK83RObMA0XCZ+LUDdneNkSCIekO498uEKTY25FxI4Whduc/6j0wdd4IqpQvkq+/7vxSULjGIBQ== - dependencies: - "@chainsafe/ssz" "0.9.4" - "@ethereumjs/common" "^3.1.1" - "@ethereumjs/rlp" "^4.0.1" - "@ethereumjs/util" "^8.0.5" - "@ethersproject/providers" "^5.7.2" - ethereum-cryptography "^1.1.2" +"@ethereumjs/rlp@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@ethereumjs/rlp/-/rlp-5.0.1.tgz#56c5433b9242f956e354fd7e4ce3523815e24854" + integrity sha512-Ab/Hfzz+T9Zl+65Nkg+9xAmwKPLicsnQ4NW49pgvJp9ovefuic95cgOS9CbPc9izIEgsqm1UitV0uNveCvud9w== -"@ethereumjs/tx@5.0.0": +"@ethereumjs/tx@4.1.1", "@ethereumjs/tx@5.0.0", "@ethereumjs/tx@^4.1.2": version "5.0.0" resolved "https://registry.yarnpkg.com/@ethereumjs/tx/-/tx-5.0.0.tgz#975f25a67ee35bee572ece1f99cd84c45f661eee" integrity sha512-bJBC/jHVIbwvZBVsK0Ls70NzxJ8Q3UvPwskG1LO6+ryVGKY0y1bhRreo0/gR3vTkuRjD+x5QTYV6fIY16tswJA== @@ -779,16 +735,6 @@ "@ethereumjs/util" "^9.0.0" ethereum-cryptography "^2.1.2" -"@ethereumjs/tx@^4.1.2": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@ethereumjs/tx/-/tx-4.2.0.tgz#5988ae15daf5a3b3c815493bc6b495e76009e853" - integrity sha512-1nc6VO4jtFd172BbSnTnDQVr9IYBFl1y4xPzZdtkrkKIncBCkdbgfdRV+MiTkJYAtTxvV12GRZLqBFT1PNK6Yw== - dependencies: - "@ethereumjs/common" "^3.2.0" - "@ethereumjs/rlp" "^4.0.1" - "@ethereumjs/util" "^8.1.0" - ethereum-cryptography "^2.0.0" - "@ethereumjs/util@9.0.0", "@ethereumjs/util@^9.0.0": version "9.0.0" resolved "https://registry.yarnpkg.com/@ethereumjs/util/-/util-9.0.0.tgz#ac5945c629f3ab2ac584d8b12a8513e8eac29dc4" @@ -797,7 +743,7 @@ "@ethereumjs/rlp" "^5.0.0" ethereum-cryptography "^2.1.2" -"@ethereumjs/util@^8.0.5", "@ethereumjs/util@^8.1.0": +"@ethereumjs/util@^8.1.0": version "8.1.0" resolved "https://registry.yarnpkg.com/@ethereumjs/util/-/util-8.1.0.tgz#299df97fb6b034e0577ce9f94c7d9d1004409ed4" integrity sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA== @@ -806,6 +752,14 @@ ethereum-cryptography "^2.0.0" micro-ftch "^0.3.1" +"@ethereumjs/util@^9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@ethereumjs/util/-/util-9.0.1.tgz#cbe0380981263451e3080ddcd74accf4b10f8723" + integrity sha512-NdFFEzCc3H1sYkNnnySwLg6owdQMhjUc2jfuDyx8Xv162WSluCnnSKouKOSG3njGNEyy2I9NmF8zTRDwuqpZWA== + dependencies: + "@ethereumjs/rlp" "^5.0.1" + ethereum-cryptography "^2.1.2" + "@ethersproject/abi@5.7.0", "@ethersproject/abi@^5.5.0", "@ethersproject/abi@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.7.0.tgz#b3f3e045bbbeed1af3947335c247ad625a44e449" @@ -1023,7 +977,7 @@ bech32 "1.1.4" ws "7.4.6" -"@ethersproject/providers@5.7.2", "@ethersproject/providers@^5.7.2": +"@ethersproject/providers@5.7.2": version "5.7.2" resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.7.2.tgz#f8b1a4f275d7ce58cf0a2eec222269a08beb18cb" integrity sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg== @@ -2448,11 +2402,6 @@ dependencies: "@noble/hashes" "1.3.2" -"@noble/hashes@1.2.0", "@noble/hashes@~1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.2.0.tgz#a3150eeb09cc7ab207ebf6d7b9ad311a9bdbed12" - integrity sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ== - "@noble/hashes@1.3.1", "@noble/hashes@^1.3.0": version "1.3.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" @@ -2463,11 +2412,6 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== -"@noble/secp256k1@1.7.1", "@noble/secp256k1@~1.7.0": - version "1.7.1" - resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c" - integrity sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw== - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -3424,15 +3368,6 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.3.tgz#8584115565228290a6c6c4961973e0903bb3df2f" integrity sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q== -"@scure/bip32@1.1.5": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.1.5.tgz#d2ccae16dcc2e75bc1d75f5ef3c66a338d1ba300" - integrity sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw== - dependencies: - "@noble/hashes" "~1.2.0" - "@noble/secp256k1" "~1.7.0" - "@scure/base" "~1.1.0" - "@scure/bip32@1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.1.tgz#7248aea723667f98160f593d621c47e208ccbb10" @@ -3451,7 +3386,7 @@ "@noble/hashes" "~1.3.2" "@scure/base" "~1.1.2" -"@scure/bip39@1.1.1", "@scure/bip39@1.2.1": +"@scure/bip39@1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.1.tgz#5cee8978656b272a917b7871c981e0541ad6ac2a" integrity sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg== @@ -6404,11 +6339,6 @@ caniuse-lite@^1.0.30001400: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz#30f67d55a865da43e0aeec003f073ea8764d5d7c" integrity sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA== -case@^1.6.3: - version "1.6.3" - resolved "https://registry.yarnpkg.com/case/-/case-1.6.3.tgz#0a4386e3e9825351ca2e6216c60467ff5f1ea1c9" - integrity sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ== - caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -8442,16 +8372,6 @@ ethereum-cryptography@2.1.2, ethereum-cryptography@^2.0.0, ethereum-cryptography "@scure/bip32" "1.3.1" "@scure/bip39" "1.2.1" -ethereum-cryptography@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-1.2.0.tgz#5ccfa183e85fdaf9f9b299a79430c044268c9b3a" - integrity sha512-6yFQC9b5ug6/17CQpCyE3k9eKBMdhyVjzUy1WkiuY/E4vj/SXDBbCw8QEIaXqf0Mf2SnY6RmpDcwlUmBSS0EJw== - dependencies: - "@noble/hashes" "1.2.0" - "@noble/secp256k1" "1.7.1" - "@scure/bip32" "1.1.5" - "@scure/bip39" "1.1.1" - ethers@5.7.2, ethers@^5.0.0, ethers@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" From 0633f1fc20c0cec50c600d1f1312bed8d08e0bc5 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Wed, 17 Jan 2024 15:21:34 +0100 Subject: [PATCH 09/30] feat(gridplus): add a handler for personal signature --- src/analytics/identify/walletTypes.ts | 3 + src/core/keychain/hdPath.ts | 4 +- .../keychainTypes/hardwareWalletKeychain.ts | 2 +- src/core/types/walletTypes.ts | 1 + src/entries/popup/App.tsx | 9 ++ .../HWRequestListener/HWRequestListener.tsx | 2 +- src/entries/popup/handlers/gridplus.ts | 130 ++++++++++++++++-- src/entries/popup/handlers/wallet.ts | 23 +++- .../popup/pages/hw/addByIndexSheet.tsx | 2 +- src/entries/popup/pages/hw/gridplus.tsx | 2 +- .../popup/pages/hw/gridplus/addressChoice.tsx | 12 +- .../popup/pages/hw/walletList/index.tsx | 2 +- static/vendor/trezor-connect.js | 18 +-- 13 files changed, 176 insertions(+), 34 deletions(-) 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..752a773a98 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) { @@ -17,6 +17,8 @@ export const getHDPathForVendorAndType = ( } case 'Trezor': return `${DEFAULT_HD_PATH}/${index}`; + case 'GridPlus': + return `${DEFAULT_HD_PATH}/${index}`; default: return `${DEFAULT_HD_PATH}/${index}`; } diff --git a/src/core/keychain/keychainTypes/hardwareWalletKeychain.ts b/src/core/keychain/keychainTypes/hardwareWalletKeychain.ts index f23714fab5..c0deef1002 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/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 52ec722ce3..34902ff63b 100644 --- a/src/entries/popup/App.tsx +++ b/src/entries/popup/App.tsx @@ -29,10 +29,18 @@ import { useIsFullScreen } from './hooks/useIsFullScreen'; import usePrevious from './hooks/usePrevious'; import { PlaygroundComponents } from './pages/_playgrounds'; import { RainbowConnector } from './wagmi/RainbowConnector'; +import { setup } from 'gridplus-sdk'; const playground = process.env.PLAYGROUND as 'default' | 'ds'; const backgroundMessenger = initializeMessenger({ connect: 'background' }); +const getStoredClient = () => localStorage.getItem('storedClient') || ''; + +const setStoredClient = (storedClient: string | null) => { + if (!storedClient) return; + localStorage.setItem('storedClient', storedClient); +}; + export function App() { const { currentLanguage, setCurrentLanguage } = useCurrentLanguageStore(); const { deviceId } = useDeviceIdStore(); @@ -75,6 +83,7 @@ export function App() { analytics.track(event.popupOpened); setTimeout(() => flushQueuedEvents(), 1000); } + setup({ getStoredClient, setStoredClient, name: 'Rainbow' }); // Init trezor once globally window.TrezorConnect?.init({ manifest: { diff --git a/src/entries/popup/components/HWRequestListener/HWRequestListener.tsx b/src/entries/popup/components/HWRequestListener/HWRequestListener.tsx index 4c36e81170..47a8d07989 100644 --- a/src/entries/popup/components/HWRequestListener/HWRequestListener.tsx +++ b/src/entries/popup/components/HWRequestListener/HWRequestListener.tsx @@ -17,7 +17,7 @@ export const HWRequestListener = () => { 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 index 48a90c2297..1b48e915ec 100644 --- a/src/entries/popup/handlers/gridplus.ts +++ b/src/entries/popup/handlers/gridplus.ts @@ -5,6 +5,8 @@ import { TransactionResponse, } from '@ethersproject/abstract-provider'; import { BigNumber } from '@ethersproject/bignumber'; +import { Bytes, hexlify, joinSignature } from '@ethersproject/bytes'; +import { toUtf8Bytes } from '@ethersproject/strings'; import { UnsignedTransaction, parse, @@ -12,22 +14,21 @@ import { } from '@ethersproject/transactions'; import { ChainId } from '@rainbow-me/swaps'; import { getProvider } from '@wagmi/core'; -import { sign as gridPlusSign, setup } from 'gridplus-sdk'; +import { + sign as gridPlusSign, + signMessage as gridPlusSignMessage, +} from 'gridplus-sdk'; +import { Address } from 'wagmi'; +import { getPath } from '~/core/keychain'; import { LEGACY_CHAINS_FOR_HW } from '~/core/references'; - -const getStoredClient = () => localStorage.getItem('storedClient') || ''; - -const setStoredClient = (storedClient: string | null) => { - if (!storedClient) return; - localStorage.setItem('storedClient', storedClient); -}; +import { addHexPrefix } from '~/core/utils/hex'; +import { logger } from '~/logger'; export async function signTransactionFromGridPlus( transaction: TransactionRequest, ) { try { - await setup({ getStoredClient, setStoredClient, name: 'Rainbow' }); const { from: address } = transaction; const baseTx: UnsignedTransaction = { chainId: transaction.chainId || undefined, @@ -72,9 +73,11 @@ export async function signTransactionFromGridPlus( const response = await gridPlusSign(txPayload.getMessageToSign() as Buffer); - const r = '0x' + response.sig.r.toString('hex'); - const s = '0x' + response.sig.s.toString('hex'); - const v = BigNumber.from('0x' + response.sig.v.toString('hex')).toNumber(); + 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) { if (baseTx.gasLimit) { @@ -88,7 +91,6 @@ export async function signTransactionFromGridPlus( const parsedTx = parse(serializedTransaction); if (parsedTx.from?.toLowerCase() !== address?.toLowerCase()) { - console.log('>>>PARSED_TX', parsedTx); throw new Error('Transaction was not signed by the right address'); } @@ -118,3 +120,105 @@ export async function sendTransactionFromGridPlus( }); return provider.sendTransaction(serializedTransaction as string); } + +export async function signMessageByTypeFromGridPlus( + msgData: string | Bytes, + address: Address, + messageType: string, +): Promise { + const path = await getPath(address.toLowerCase() as Address); + // Personal sign + if (messageType === 'personal_sign') { + if (typeof msgData === 'string') { + try { + // eslint-disable-next-line no-param-reassign + msgData = toUtf8Bytes(msgData); + } catch (e) { + logger.info('the message is not a utf8 string, will sign as hex'); + } + } + + const messageHex = hexlify(msgData).substring(2); + + const addressIndex = parseInt(path.split('/')[5]); + + const response = await gridPlusSignMessage(messageHex, { + signerPath: [ + 0x80000000 + 44, + 0x80000000 + 60, + 0x80000000, + 0, + addressIndex, + ], + }); + + 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; + const version = SignTypedDataVersion.V4; + 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 { domain_separator_hash, message_hash } = transformTypedDataPlugin( + eip712Data, + true, + ); + + const response = await window.TrezorConnect.ethereumSignTypedData({ + path, + data: eip712Data, + metamask_v4_compat: true, + domain_separator_hash, + message_hash, + }); + + if (!response.success) { + throw new Error('Trezor returned an error'); + } + + if (response.payload.address.toLowerCase() !== address.toLowerCase()) { + throw new Error( + 'Trezor returned a different address than the one requested', + ); + } + return addHexPrefix(response.payload.signature); + } else { + throw new Error(`Message type ${messageType} not supported`); + } +} diff --git a/src/entries/popup/handlers/wallet.ts b/src/entries/popup/handlers/wallet.ts index b9ef49fc15..caf794c36d 100644 --- a/src/entries/popup/handlers/wallet.ts +++ b/src/entries/popup/handlers/wallet.ts @@ -33,7 +33,11 @@ import { RainbowError, logger } from '~/logger'; import { PathOptions } from '../pages/hw/addByIndexSheet'; -import { sendTransactionFromGridPlus } from './gridplus'; +import { + sendTransactionFromGridPlus, + signMessageByTypeFromGridPlus, + signTransactionFromGridPlus, +} from './gridplus'; import { sendTransactionFromLedger, signMessageByTypeFromLedger, @@ -91,6 +95,8 @@ export const signTransactionFromHW = async ( return signTransactionFromLedger(params); } else if (vendor === 'Trezor') { return signTransactionFromTrezor(params); + } else if (vendor === 'GridPlus') { + return signTransactionFromGridPlus(params); } }; @@ -189,13 +195,15 @@ export const personalSign = async ( msgData: string | Bytes, address: Address, ): Promise => { - const { type, vendor } = await getWallet(address as Address); + const { type, vendor } = await getWallet(address.toLowerCase() as Address); if (type === 'HardwareWalletKeychain') { switch (vendor) { case 'Ledger': 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 +221,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 +341,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, ) => { 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/gridplus.tsx b/src/entries/popup/pages/hw/gridplus.tsx index 5c117cac05..650aa36359 100644 --- a/src/entries/popup/pages/hw/gridplus.tsx +++ b/src/entries/popup/pages/hw/gridplus.tsx @@ -68,7 +68,7 @@ export function ConnectGridPlus() { navigate(ROUTES.HW_WALLET_LIST, { state: { accountsToImport, - deviceId: 'Test', + deviceId: 'GridPlus', accountsEnabled: accountsToImport.length, vendor: 'GridPlus', direction: state?.direction, diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index e5f48c6c17..0f17f009a7 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -6,6 +6,7 @@ import { Address } from 'wagmi'; import { truncateAddress } from '~/core/utils/address'; import { Box, Button, Text } from '~/design-system'; import { Checkbox } from '~/entries/popup/components/Checkbox/Checkbox'; +import { Spinner } from '~/entries/popup/components/Spinner/Spinner'; export type AddressesData = { addresses: Address[]; @@ -19,6 +20,7 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { const [formData, setFormData] = useState({ selectedAddresses: [] as string[], }); + const [loadingAddresses, setLoadingAddresses] = useState(true); const [addresses, setAddresses] = useState([]); const toggleAddress = (address: string) => { const selected = formData.selectedAddresses.includes(address); @@ -38,8 +40,10 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { }; useEffect(() => { const fetchWalletAddresses = async () => { + setLoadingAddresses(true); const fetchedAddresses = (await fetchAddresses()) as Address[]; setAddresses(fetchedAddresses); + setLoadingAddresses(false); }; fetchWalletAddresses(); }, []); @@ -55,6 +59,7 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { Choose Addresses + {loadingAddresses && } {addresses.map((address) => ( @@ -69,7 +74,12 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { ))} -
diff --git a/src/entries/popup/pages/hw/walletList/index.tsx b/src/entries/popup/pages/hw/walletList/index.tsx index 12c27318bb..048b0b3f16 100644 --- a/src/entries/popup/pages/hw/walletList/index.tsx +++ b/src/entries/popup/pages/hw/walletList/index.tsx @@ -438,7 +438,7 @@ const WalletListHW = () => { diff --git a/static/vendor/trezor-connect.js b/static/vendor/trezor-connect.js index 2db1da3562..7d4911bb02 100644 --- a/static/vendor/trezor-connect.js +++ b/static/vendor/trezor-connect.js @@ -20168,7 +20168,7 @@ function __classPrivateFieldIn(state, receiver) { /************************************************************************/ /******/ // The module cache /******/ var __webpack_module_cache__ = {}; -/******/ +/******/ /******/ // The require function /******/ function __webpack_require__(moduleId) { /******/ // Check if module is in cache @@ -20182,14 +20182,14 @@ function __classPrivateFieldIn(state, receiver) { /******/ // no module.loaded needed /******/ exports: {} /******/ }; -/******/ +/******/ /******/ // Execute the module function /******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__); -/******/ +/******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } -/******/ +/******/ /************************************************************************/ /******/ /* webpack/runtime/compat get default export */ /******/ (() => { @@ -20202,7 +20202,7 @@ function __classPrivateFieldIn(state, receiver) { /******/ return getter; /******/ }; /******/ })(); -/******/ +/******/ /******/ /* webpack/runtime/define property getters */ /******/ (() => { /******/ // define getter functions for harmony exports @@ -20214,7 +20214,7 @@ function __classPrivateFieldIn(state, receiver) { /******/ } /******/ }; /******/ })(); -/******/ +/******/ /******/ /* webpack/runtime/global */ /******/ (() => { /******/ __webpack_require__.g = (function() { @@ -20226,12 +20226,12 @@ function __classPrivateFieldIn(state, receiver) { /******/ } /******/ })(); /******/ })(); -/******/ +/******/ /******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ (() => { /******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) /******/ })(); -/******/ +/******/ /******/ /* webpack/runtime/make namespace object */ /******/ (() => { /******/ // define __esModule on exports @@ -20242,7 +20242,7 @@ function __classPrivateFieldIn(state, receiver) { /******/ Object.defineProperty(exports, '__esModule', { value: true }); /******/ }; /******/ })(); -/******/ +/******/ /************************************************************************/ var __webpack_exports__ = {}; // This entry need to be wrapped in an IIFE because it need to be in strict mode. From fc486e30b8c92f9778aae874d1b22a28f1b821e1 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Thu, 18 Jan 2024 13:40:06 +0100 Subject: [PATCH 10/30] feat(gridplus): wrap up typed data signing --- src/entries/popup/handlers/gridplus.ts | 62 ++++++++++++++------------ src/entries/popup/handlers/wallet.ts | 2 +- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/entries/popup/handlers/gridplus.ts b/src/entries/popup/handlers/gridplus.ts index 1b48e915ec..06e1cd3171 100644 --- a/src/entries/popup/handlers/gridplus.ts +++ b/src/entries/popup/handlers/gridplus.ts @@ -12,6 +12,7 @@ import { parse, serialize, } from '@ethersproject/transactions'; +import { TypedDataUtils } from '@metamask/eth-sig-util'; import { ChainId } from '@rainbow-me/swaps'; import { getProvider } from '@wagmi/core'; import { @@ -19,8 +20,8 @@ import { signMessage as gridPlusSignMessage, } from 'gridplus-sdk'; import { Address } from 'wagmi'; -import { getPath } from '~/core/keychain'; +import { getPath } from '~/core/keychain'; import { LEGACY_CHAINS_FOR_HW } from '~/core/references'; import { addHexPrefix } from '~/core/utils/hex'; import { logger } from '~/logger'; @@ -127,6 +128,14 @@ export async function signMessageByTypeFromGridPlus( messageType: string, ): Promise { const path = await getPath(address.toLowerCase() as Address); + const addressIndex = parseInt(path.split('/')[5]); + const signerPath = [ + 0x80000000 + 44, + 0x80000000 + 60, + 0x80000000, + 0, + addressIndex, + ]; // Personal sign if (messageType === 'personal_sign') { if (typeof msgData === 'string') { @@ -140,16 +149,10 @@ export async function signMessageByTypeFromGridPlus( const messageHex = hexlify(msgData).substring(2); - const addressIndex = parseInt(path.split('/')[5]); - const response = await gridPlusSignMessage(messageHex, { - signerPath: [ - 0x80000000 + 44, - 0x80000000 + 60, - 0x80000000, - 0, - addressIndex, - ], + signerPath, + payload: messageHex, + protocol: 'signPersonal', }); const responseAddress = hexlify(response.signer); @@ -177,7 +180,6 @@ export async function signMessageByTypeFromGridPlus( } else if (messageType === 'sign_typed_data') { // eslint-disable-next-line @typescript-eslint/no-explicit-any const parsedData = msgData as any; - const version = SignTypedDataVersion.V4; if ( typeof msgData !== 'object' || !(parsedData.types || parsedData.primaryType || parsedData.domain) @@ -195,29 +197,33 @@ export async function signMessageByTypeFromGridPlus( message, }; - const { domain_separator_hash, message_hash } = transformTypedDataPlugin( - eip712Data, - true, - ); - - const response = await window.TrezorConnect.ethereumSignTypedData({ - path, - data: eip712Data, - metamask_v4_compat: true, - domain_separator_hash, - message_hash, + const response = await gridPlusSignMessage(eip712Data, { + signerPath, + protocol: 'eip712', + payload: eip712Data, }); - if (!response.success) { - throw new Error('Trezor returned an error'); - } + const responseAddress = hexlify(response.signer); - if (response.payload.address.toLowerCase() !== address.toLowerCase()) { + if (responseAddress.toLowerCase() !== address.toLowerCase()) { throw new Error( - 'Trezor returned a different address than the one requested', + 'GridPlus returned a different address than the one requested', ); } - return addHexPrefix(response.payload.signature); + + 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/wallet.ts b/src/entries/popup/handlers/wallet.ts index caf794c36d..c69e81e69f 100644 --- a/src/entries/popup/handlers/wallet.ts +++ b/src/entries/popup/handlers/wallet.ts @@ -216,7 +216,7 @@ export const signTypedData = async ( msgData: string | Bytes, address: Address, ) => { - const { type, vendor } = await getWallet(address as Address); + const { type, vendor } = await getWallet(address.toLowerCase() as Address); if (type === 'HardwareWalletKeychain') { switch (vendor) { case 'Ledger': From 6c0ba38907b5fb7a765542e37701abf9cbc8ebac Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Thu, 18 Jan 2024 15:29:00 +0100 Subject: [PATCH 11/30] fix(gridplus): fix personal sign --- src/entries/popup/handlers/gridplus.ts | 19 +++---------------- src/entries/popup/handlers/wallet.ts | 4 ++-- .../popup/pages/hw/gridplus/addressChoice.tsx | 6 +++++- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/entries/popup/handlers/gridplus.ts b/src/entries/popup/handlers/gridplus.ts index 06e1cd3171..e3ca9ff0f4 100644 --- a/src/entries/popup/handlers/gridplus.ts +++ b/src/entries/popup/handlers/gridplus.ts @@ -6,7 +6,6 @@ import { } from '@ethersproject/abstract-provider'; import { BigNumber } from '@ethersproject/bignumber'; import { Bytes, hexlify, joinSignature } from '@ethersproject/bytes'; -import { toUtf8Bytes } from '@ethersproject/strings'; import { UnsignedTransaction, parse, @@ -24,7 +23,6 @@ import { Address } from 'wagmi'; import { getPath } from '~/core/keychain'; import { LEGACY_CHAINS_FOR_HW } from '~/core/references'; import { addHexPrefix } from '~/core/utils/hex'; -import { logger } from '~/logger'; export async function signTransactionFromGridPlus( transaction: TransactionRequest, @@ -127,7 +125,7 @@ export async function signMessageByTypeFromGridPlus( address: Address, messageType: string, ): Promise { - const path = await getPath(address.toLowerCase() as Address); + const path = await getPath(address as Address); const addressIndex = parseInt(path.split('/')[5]); const signerPath = [ 0x80000000 + 44, @@ -138,20 +136,9 @@ export async function signMessageByTypeFromGridPlus( ]; // Personal sign if (messageType === 'personal_sign') { - if (typeof msgData === 'string') { - try { - // eslint-disable-next-line no-param-reassign - msgData = toUtf8Bytes(msgData); - } catch (e) { - logger.info('the message is not a utf8 string, will sign as hex'); - } - } - - const messageHex = hexlify(msgData).substring(2); - - const response = await gridPlusSignMessage(messageHex, { + const response = await gridPlusSignMessage(msgData, { signerPath, - payload: messageHex, + payload: msgData, protocol: 'signPersonal', }); diff --git a/src/entries/popup/handlers/wallet.ts b/src/entries/popup/handlers/wallet.ts index c69e81e69f..c6068c7c48 100644 --- a/src/entries/popup/handlers/wallet.ts +++ b/src/entries/popup/handlers/wallet.ts @@ -195,7 +195,7 @@ export const personalSign = async ( msgData: string | Bytes, address: Address, ): Promise => { - const { type, vendor } = await getWallet(address.toLowerCase() as Address); + const { type, vendor } = await getWallet(address as Address); if (type === 'HardwareWalletKeychain') { switch (vendor) { case 'Ledger': @@ -216,7 +216,7 @@ export const signTypedData = async ( msgData: string | Bytes, address: Address, ) => { - const { type, vendor } = await getWallet(address.toLowerCase() as Address); + const { type, vendor } = await getWallet(address as Address); if (type === 'HardwareWalletKeychain') { switch (vendor) { case 'Ledger': diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index 0f17f009a7..0e5d5696e8 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -1,3 +1,4 @@ +import { getAddress } from '@ethersproject/address'; import { motion } from 'framer-motion'; import { fetchAddresses } from 'gridplus-sdk'; import { FormEvent, useEffect, useState } from 'react'; @@ -42,7 +43,10 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { const fetchWalletAddresses = async () => { setLoadingAddresses(true); const fetchedAddresses = (await fetchAddresses()) as Address[]; - setAddresses(fetchedAddresses); + const mixedCaseAddresses = fetchedAddresses.map((address) => + getAddress(address), + ); + setAddresses(mixedCaseAddresses); setLoadingAddresses(false); }; fetchWalletAddresses(); From cace36144dbf33ddd9bb314ce6f5de6418a05cec Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Wed, 24 Jan 2024 16:02:35 +0100 Subject: [PATCH 12/30] chore(e2e): add semi-automatic testing for GridPlus --- e2e/helpers.ts | 65 ++++++++++++++++++- e2e/parallel/GridPlusImportFlow.test.ts | 37 +++++++++++ src/entries/popup/App.tsx | 4 +- .../popup/components/Checkbox/Checkbox.tsx | 3 + .../popup/pages/hw/gridplus/addressChoice.tsx | 8 ++- .../popup/pages/hw/gridplus/pairingSecret.tsx | 24 +++++-- .../pages/hw/gridplus/walletCredentials.tsx | 36 +++++++--- 7 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 e2e/parallel/GridPlusImportFlow.test.ts diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 3d1b4b663e..b690fb2621 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -797,6 +797,67 @@ export async function importHardwareWalletFlow( await findElementByText(driver, 'Rainbow is ready to use'); } +export async function importGridPlusWallet(driver: WebDriver, rootURL: string) { + const { env } = import.meta as unknown as { + env: { GRIDPLUS_DEVICE_ID: string; GRIDPLUS_DEVICE_PASSWORD: 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(env.GRIDPLUS_DEVICE_ID); + const inputPassword = await findElementByTestId({ + id: 'gridplus-password', + driver, + }); + await inputPassword.sendKeys(env.GRIDPLUS_DEVICE_PASSWORD); + await delayTime('long'); + await findElementByTestIdAndClick({ + id: 'gridplus-submit', + driver, + }); + const inputPairingCode = await findElementByTestId({ + id: 'gridplus-pairing-code', + driver, + }); + inputPairingCode.click(); + await delayTime('unbelievably-long'); + await findElementByTestIdAndClick({ + id: 'gridplus-submit', + driver, + }); + await delayTime('very-long'); + 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, @@ -1055,7 +1116,7 @@ export async function delay(ms: number) { } export async function delayTime( - time: 'short' | 'medium' | 'long' | 'very-long', + time: 'short' | 'medium' | 'long' | 'very-long' | 'unbelievably-long', ) { switch (time) { case 'short': @@ -1066,6 +1127,8 @@ export async function delayTime( return await delay(1000); case 'very-long': return await delay(5000); + case 'unbelievably-long': + return await delay(15000); } } 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/src/entries/popup/App.tsx b/src/entries/popup/App.tsx index 34902ff63b..4a1551743a 100644 --- a/src/entries/popup/App.tsx +++ b/src/entries/popup/App.tsx @@ -1,4 +1,5 @@ import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { setup } from 'gridplus-sdk'; import { isEqual } from 'lodash'; import * as React from 'react'; import { WagmiConfig } from 'wagmi'; @@ -29,12 +30,11 @@ import { useIsFullScreen } from './hooks/useIsFullScreen'; import usePrevious from './hooks/usePrevious'; import { PlaygroundComponents } from './pages/_playgrounds'; import { RainbowConnector } from './wagmi/RainbowConnector'; -import { setup } from 'gridplus-sdk'; const playground = process.env.PLAYGROUND as 'default' | 'ds'; const backgroundMessenger = initializeMessenger({ connect: 'background' }); -const getStoredClient = () => localStorage.getItem('storedClient') || ''; +const getStoredClient = () => localStorage.getItem('storedClient') ?? ''; const setStoredClient = (storedClient: string | null) => { if (!storedClient) return; diff --git a/src/entries/popup/components/Checkbox/Checkbox.tsx b/src/entries/popup/components/Checkbox/Checkbox.tsx index 44fccb7b78..7763eff5d4 100644 --- a/src/entries/popup/components/Checkbox/Checkbox.tsx +++ b/src/entries/popup/components/Checkbox/Checkbox.tsx @@ -14,6 +14,7 @@ export function Checkbox({ backgroundSelected = 'accent', borderColor = 'separatorSecondary', borderColorSelected = 'accent', + testId, }: { selected: boolean; onClick?: () => void; @@ -24,6 +25,7 @@ export function Checkbox({ borderColorSelected?: BoxStyles['borderColor']; background?: 'accent' | BackgroundColor; backgroundSelected?: 'accent' | BackgroundColor; + testId?: string; }) { return ( { Choose Addresses - {loadingAddresses && } + {loadingAddresses && } - {addresses.map((address) => ( + {addresses.map((address, i) => ( toggleAddress(address)} selected={formData.selectedAddresses.includes(address)} + testId={`gridplus-address-${i}`} /> {truncateAddress(address)} @@ -82,7 +83,8 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { height="36px" variant="flat" color="fill" - disabled={loadingAddresses} + disabled={loadingAddresses || formData.selectedAddresses.length === 0} + testId="gridplus-submit" > Export Addresses diff --git a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx index b2caa74143..6da7c0ae5a 100644 --- a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx +++ b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx @@ -10,6 +10,7 @@ export type PairingSecretProps = { }; export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { + const [pairing, setPairing] = useState(false); const [formState, setFormState] = useState({ error: false, }); @@ -18,11 +19,16 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { }); const onSubmit = async (event: FormEvent) => { event.preventDefault(); - const result = await pair(formData.pairingCode); - if (!result) { - return setFormState({ error: true }); + setPairing(true); + try { + const result = await pair(formData.pairingCode); + if (!result) { + return setFormState({ error: true }); + } + onAfterPair && onAfterPair(); + } finally { + setPairing(false); } - onAfterPair && onAfterPair(); }; return ( { setFormData({ ...formData, pairingCode: e.target.value }) } value={formData.pairingCode} + testId="gridplus-pairing-code" + autoFocus /> {formState.error && ( @@ -56,7 +64,13 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { )} - diff --git a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx index a8e5e329c8..2555dbf7b9 100644 --- a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -15,11 +15,12 @@ export const WalletCredentials = ({ appName, onAfterSetup, }: WalletCredentialsProps) => { + const [connecting, setConnecting] = useState(false); const [formData, setFormData] = useState({ deviceId: '', password: '', }); - const getStoredClient = () => localStorage.getItem('storedClient') || ''; + const getStoredClient = () => localStorage.getItem('storedClient') ?? ''; const setStoredClient = (storedClient: string | null) => { if (!storedClient) return; @@ -27,14 +28,19 @@ export const WalletCredentials = ({ }; const onSubmit = async (event: FormEvent) => { event.preventDefault(); - const result = await setup({ - deviceId: formData.deviceId, - password: formData.password, - name: appName, - getStoredClient, - setStoredClient, - }); - onAfterSetup && onAfterSetup(result); + setConnecting(true); + try { + const result = await setup({ + deviceId: formData.deviceId, + password: formData.password, + name: appName, + getStoredClient, + setStoredClient, + }); + onAfterSetup && onAfterSetup(result); + } finally { + setConnecting(false); + } }; useEffect(() => { if (getStoredClient()) { @@ -66,6 +72,8 @@ export const WalletCredentials = ({ setFormData({ ...formData, deviceId: e.target.value }) } value={formData.deviceId} + testId="gridplus-deviceid" + aria-label="username" /> @@ -82,9 +90,17 @@ export const WalletCredentials = ({ setFormData({ ...formData, password: e.target.value }) } value={formData.password} + testId="gridplus-password" + aria-label="password" /> - From 44b9a9626d31a484bdcd2d666692aab12ed5f7fa Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Fri, 26 Jan 2024 16:17:18 +0100 Subject: [PATCH 13/30] chore(gridplus): update image assets --- .../popup/pages/hw/gridplus/addressChoice.tsx | 5 +++-- .../popup/pages/hw/gridplus/pairingSecret.tsx | 9 +++++---- .../pages/hw/gridplus/walletCredentials.tsx | 6 +++--- static/assets/hw/grid-plus-logo.png | Bin 5233 -> 4984 bytes static/json/languages/en_US.json | 11 ++++++++++- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index 937dd5a8a7..35339ee438 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -4,6 +4,7 @@ import { fetchAddresses } from 'gridplus-sdk'; import { FormEvent, useEffect, useState } from 'react'; import { Address } from 'wagmi'; +import { i18n } from '~/core/languages'; import { truncateAddress } from '~/core/utils/address'; import { Box, Button, Text } from '~/design-system'; import { Checkbox } from '~/entries/popup/components/Checkbox/Checkbox'; @@ -61,7 +62,7 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { width="full" > - Choose Addresses + {i18n.t('hw.gridplus_choose_addresses')} {loadingAddresses && } @@ -86,7 +87,7 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { disabled={loadingAddresses || formData.selectedAddresses.length === 0} testId="gridplus-submit" > - Export Addresses + {i18n.t('hw.gridplus_export_addresses')} ); diff --git a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx index 6da7c0ae5a..4e3fc36620 100644 --- a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx +++ b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx @@ -2,6 +2,7 @@ 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'; @@ -40,11 +41,11 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { width="full" > - Check your Lattice1 device for the pairing secret. + {i18n.t('hw.gridplus_check_device')} - Pairing Code + {i18n.t('hw.gridplus_pairing_code')} { /> {formState.error && ( - Wrong pairing code + {i18n.t('hw.gridplus_wrong_code')} )} @@ -71,7 +72,7 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { testId="gridplus-submit" disabled={pairing} > - Pair Device + {i18n.t('hw.gridplus_pair_device')}
); diff --git a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx index 2555dbf7b9..203a7af1c5 100644 --- a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -61,7 +61,7 @@ export const WalletCredentials = ({ - Device ID + {i18n.t('hw.gridplus_device_id')} - Password + {i18n.t('hw.gridplus_password')} - Connect + {i18n.t('hw.gridplus_connect')} ); diff --git a/static/assets/hw/grid-plus-logo.png b/static/assets/hw/grid-plus-logo.png index 75f7cf74277c6445a84deb974fe6c0ae50134406..1fdab958661c1ca03613250c9b2ce4658ed94f52 100644 GIT binary patch literal 4984 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 literal 5233 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 NfUyA@Rflwb@E=ds4gLTC diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index fe960f27cf..e2bf821080 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -1479,7 +1479,16 @@ "choose_title": "Connect a hardware wallet", "ledger_support": "Supports Ledger Nano S, Nano S Plus, Nano X, and Stax devices.", "trezor_support": "Supports Model One or Model T devices.", - "gridplus_support": "Supports Lattice1.", + "gridplus_support": "Supports the GridPlus Lattice1 and SafeCards.", + "gridplus_device_id": "Device ID", + "gridplus_connect": "Connect", + "gridplus_password": "GridPlus Password", + "gridplus_pair_device": "Pair Device", + "gridplus_pairing_code": "Pairing Code", + "gridplus_wrong_code": "Wrong pairing code.", + "gridplus_check_device": "Check your Lattice1 device for the pairing secret.", + "gridplus_choose_addresses": "Choose Addresses", + "gridplus_export_addresses": "Export Addresses", "connect_ledger_title": "Connect your Ledger", "connect_ledger_description": "Connect your Ledger to Rainbow by plugging it into your computer using the USB-C cable.", "unlock_ledger_title": "Unlock Ledger", From 36dd23178ade04084cc2678758f959fffeb63398 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Thu, 1 Feb 2024 16:44:13 +0100 Subject: [PATCH 14/30] chore(gridplus): improve onboarding ui --- src/design-system/components/Text/Text.tsx | 16 ++- .../components/TextOverflow/TextOverflow.tsx | 13 +- .../popup/components/Checkbox/Checkbox.tsx | 3 + src/entries/popup/pages/hw/gridplus.tsx | 3 + .../popup/pages/hw/gridplus/addressChoice.tsx | 89 ++++++++++---- .../popup/pages/hw/gridplus/pairingSecret.tsx | 69 +++++++---- .../pages/hw/gridplus/walletCredentials.tsx | 111 +++++++++++------- static/json/languages/en_US.json | 4 +- 8 files changed, 210 insertions(+), 98 deletions(-) diff --git a/src/design-system/components/Text/Text.tsx b/src/design-system/components/Text/Text.tsx index dea3afffc3..0f1032da9b 100644 --- a/src/design-system/components/Text/Text.tsx +++ b/src/design-system/components/Text/Text.tsx @@ -8,7 +8,18 @@ import { selectionStyle } from './Text.css'; export interface TextProps { align?: TextStyles['textAlign']; - as?: 'div' | 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'pre'; + as?: + | 'div' + | 'p' + | 'span' + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'h6' + | 'pre' + | 'label'; children: React.ReactNode; color?: TextStyles['color']; size: TextStyles['fontSize']; @@ -21,6 +32,7 @@ export interface TextProps { whiteSpace?: TextStyles['whiteSpace']; textShadow?: TextStyles['textShadow']; fontFamily?: TextStyles['fontFamily']; + htmlFor?: string; } export function Text({ @@ -38,6 +50,7 @@ export function Text({ whiteSpace, textShadow, fontFamily = 'rounded', + htmlFor, }: TextProps) { return ( diff --git a/src/design-system/components/TextOverflow/TextOverflow.tsx b/src/design-system/components/TextOverflow/TextOverflow.tsx index d460296f20..dda8b6a621 100644 --- a/src/design-system/components/TextOverflow/TextOverflow.tsx +++ b/src/design-system/components/TextOverflow/TextOverflow.tsx @@ -6,7 +6,18 @@ import { Inset } from '../Inset/Inset'; interface TextOverflowProps { align?: TextStyles['textAlign']; - as?: 'div' | 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'pre'; + as?: + | 'div' + | 'p' + | 'span' + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'h6' + | 'pre' + | 'label'; children: React.ReactNode; color?: TextStyles['color']; size: TextStyles['fontSize']; diff --git a/src/entries/popup/components/Checkbox/Checkbox.tsx b/src/entries/popup/components/Checkbox/Checkbox.tsx index 7763eff5d4..66264a8bb9 100644 --- a/src/entries/popup/components/Checkbox/Checkbox.tsx +++ b/src/entries/popup/components/Checkbox/Checkbox.tsx @@ -15,6 +15,7 @@ export function Checkbox({ borderColor = 'separatorSecondary', borderColorSelected = 'accent', testId, + id, }: { selected: boolean; onClick?: () => void; @@ -26,9 +27,11 @@ export function Checkbox({ background?: 'accent' | BackgroundColor; backgroundSelected?: 'accent' | BackgroundColor; testId?: string; + id?: string; }) { return ( diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index 35339ee438..9d9eddbd94 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -9,6 +9,7 @@ import { truncateAddress } from '~/core/utils/address'; import { Box, Button, Text } from '~/design-system'; import { Checkbox } from '~/entries/popup/components/Checkbox/Checkbox'; import { Spinner } from '~/entries/popup/components/Spinner/Spinner'; +import { useAccounts } from '~/entries/popup/hooks/useAccounts'; export type AddressesData = { addresses: Address[]; @@ -19,11 +20,13 @@ export type AddressChoiceProps = { }; export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { + const { sortedAccounts } = useAccounts(); const [formData, setFormData] = useState({ selectedAddresses: [] as string[], }); const [loadingAddresses, setLoadingAddresses] = useState(true); const [addresses, setAddresses] = useState([]); + const disabled = loadingAddresses || formData.selectedAddresses.length === 0; const toggleAddress = (address: string) => { const selected = formData.selectedAddresses.includes(address); if (selected) @@ -44,48 +47,82 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { const fetchWalletAddresses = async () => { setLoadingAddresses(true); const fetchedAddresses = (await fetchAddresses()) as Address[]; - const mixedCaseAddresses = fetchedAddresses.map((address) => - getAddress(address), - ); - setAddresses(mixedCaseAddresses); + const nonExistingAddresses = fetchedAddresses + .map((address) => getAddress(address)) + .filter( + (address) => + !sortedAccounts.map((account) => account.address).includes(address), + ); + setAddresses(nonExistingAddresses); setLoadingAddresses(false); }; + const setPersistedFormData = () => { + const persistedAddresses = JSON.parse( + sessionStorage.getItem('gridplusPersistedAddresses') ?? '[]', + ) as string[]; + if (persistedAddresses.length < 1) return; + setFormData({ selectedAddresses: persistedAddresses }); + }; fetchWalletAddresses(); + setPersistedFormData(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + sessionStorage.setItem( + 'gridplusPersistedAddresses', + JSON.stringify(formData.selectedAddresses), + ); + }, [formData.selectedAddresses]); return ( - - {i18n.t('hw.gridplus_choose_addresses')} - - {loadingAddresses && } - - {addresses.map((address, i) => ( - - toggleAddress(address)} - selected={formData.selectedAddresses.includes(address)} - testId={`gridplus-address-${i}`} - /> - - {truncateAddress(address)} - - - ))} + + + {i18n.t('hw.gridplus_choose_addresses')} + + {loadingAddresses && } + + {addresses.map((address, i) => ( + + toggleAddress(address)} + selected={formData.selectedAddresses.includes(address)} + testId={`gridplus-address-${i}`} + /> + + #{i}: + + + {truncateAddress(address)} + + + ))} + diff --git a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx index 4e3fc36620..370507b923 100644 --- a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx +++ b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx @@ -18,6 +18,7 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { const [formData, setFormData] = useState({ pairingCode: '', }); + const disabled = pairing || formData.pairingCode.length < 8; const onSubmit = async (event: FormEvent) => { event.preventDefault(); setPairing(true); @@ -37,40 +38,58 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { display="flex" flexDirection="column" onSubmit={onSubmit} - gap="16px" + flexGrow="1" + flexShrink="1" width="full" + paddingBottom="16px" > - - {i18n.t('hw.gridplus_check_device')} - - - - {i18n.t('hw.gridplus_pairing_code')} + + + {i18n.t('hw.gridplus_check_device')} - - setFormData({ ...formData, pairingCode: e.target.value }) - } - value={formData.pairingCode} - testId="gridplus-pairing-code" - autoFocus - /> - {formState.error && ( + - {i18n.t('hw.gridplus_wrong_code')} + {i18n.t('hw.gridplus_pairing_code')} - )} + + setFormData({ ...formData, pairingCode: e.target.value }) + } + value={formData.pairingCode} + testId="gridplus-pairing-code" + 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 index 203a7af1c5..d74b2fbfa0 100644 --- a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -20,6 +20,9 @@ export const WalletCredentials = ({ deviceId: '', password: '', }); + const formDataFilled = + formData.deviceId.length > 0 && formData.password.length > 0; + const disabled = !formDataFilled || connecting; const getStoredClient = () => localStorage.getItem('storedClient') ?? ''; const setStoredClient = (storedClient: string | null) => { @@ -43,63 +46,85 @@ export const WalletCredentials = ({ } }; useEffect(() => { - if (getStoredClient()) { - setup({ getStoredClient, setStoredClient, name: appName }); - } - }, [appName]); + const checkPersistedClient = async () => { + if (getStoredClient()) { + const result = await setup({ + getStoredClient, + setStoredClient, + name: appName, + }); + onAfterSetup && onAfterSetup(result); + } + }; + checkPersistedClient(); + }, [appName, onAfterSetup]); return ( - - {i18n.t('hw.connect_gridplus_title')} - - - - {i18n.t('hw.gridplus_device_id')} + + + {i18n.t('hw.connect_gridplus_title')} - - setFormData({ ...formData, deviceId: e.target.value }) - } - value={formData.deviceId} - testId="gridplus-deviceid" - aria-label="username" - /> - - - - {i18n.t('hw.gridplus_password')} + + {i18n.t('hw.connect_gridplus_description')} - - setFormData({ ...formData, password: e.target.value }) - } - value={formData.password} - testId="gridplus-password" - aria-label="password" - /> + + + {i18n.t('hw.gridplus_device_id')} + + + setFormData({ ...formData, deviceId: e.target.value }) + } + value={formData.deviceId} + testId="gridplus-deviceid" + aria-label="username" + /> + + + + {i18n.t('hw.gridplus_password')} + + + setFormData({ ...formData, password: e.target.value }) + } + value={formData.password} + testId="gridplus-password" + aria-label="password" + /> + diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index 2be17037d0..ab3e052e98 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -1562,9 +1562,9 @@ "needs_app_ledger_description": "The Ethereum app needs to be open on your Ledger to proced with the transaction.", "needs_app_ledger_description_2": "Enter your passcode to unlock your Ledger. Once unlocked, open the Ethereum app by pressing both buttons at once.", "connect_trezor_title": "Complete your Trezor set up", - "connect_gridplus_title": "Complete your Lattice1 set up", + "connect_gridplus_title": "Connect your Lattice1", "connect_trezor_description": "Continue to connect your Trezor to Rainbow through the web interface.", - "connect_gridplus_description": "Continue to connect your Lattice1 to Rainbow through the web interface.", + "connect_gridplus_description": "Connect your Lattice1 to Rainbow through the connection wizard.", "learn_more": "Learn more.", "connect_wallets_title": "Connect your wallets", "connect_wallets_found": "We’ve found %{count} wallets on your %{vendor} with a balance or activity. Select which to connect.", From f5c1b8d58ff1aa274067acdf240f37ec8d73e41d Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Fri, 2 Feb 2024 16:54:58 +0100 Subject: [PATCH 15/30] feat(gridplus): add informative labels for the onboarding --- .../popup/pages/hw/gridplus/addressChoice.tsx | 17 +++++++++---- .../popup/pages/hw/gridplus/pairingSecret.tsx | 18 +++++++++----- .../pages/hw/gridplus/walletCredentials.tsx | 24 ++++++++++++++----- static/json/languages/en_US.json | 5 +++- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index 9d9eddbd94..f4192df9aa 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -8,6 +8,7 @@ import { i18n } from '~/core/languages'; import { truncateAddress } from '~/core/utils/address'; import { Box, Button, Text } from '~/design-system'; import { Checkbox } from '~/entries/popup/components/Checkbox/Checkbox'; +import { Link } from '~/entries/popup/components/Link/Link'; import { Spinner } from '~/entries/popup/components/Spinner/Spinner'; import { useAccounts } from '~/entries/popup/hooks/useAccounts'; @@ -97,6 +98,9 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { {loadingAddresses && } + + {i18n.t('hw.available_addresses')} + {addresses.map((address, i) => ( { #{i}: - - {truncateAddress(address)} - + toggleAddress(address)} color=""> + + {truncateAddress(address)} + + ))} diff --git a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx index 370507b923..29de0d41f0 100644 --- a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx +++ b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx @@ -5,6 +5,7 @@ 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; @@ -51,7 +52,7 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { flexShrink="1" gap="24px" > - + {i18n.t('hw.gridplus_check_device')} { gap="8px" width="full" > - + {i18n.t('hw.gridplus_pairing_code')} { 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 index d74b2fbfa0..37a79d7fde 100644 --- a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -5,6 +5,7 @@ import { FormEvent, useEffect, 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 WalletCredentialsProps = { appName: string; @@ -79,11 +80,11 @@ export const WalletCredentials = ({ {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')} + - + {i18n.t('hw.gridplus_password')} + + {i18n.t('hw.gridplus_password_create')} + ); diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index ab3e052e98..54165b19c9 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -1542,14 +1542,17 @@ "trezor_support": "Supports Model One or Model T devices.", "gridplus_support": "Supports the GridPlus Lattice1 and SafeCards.", "gridplus_device_id": "Device ID", + "gridplus_device_id_description": "Get your Device ID from Lattice Home.", "gridplus_connect": "Connect", "gridplus_password": "GridPlus Password", + "gridplus_password_create": "If this is your first time logging in, create a new password.", "gridplus_pair_device": "Pair Device", "gridplus_pairing_code": "Pairing Code", "gridplus_wrong_code": "Wrong pairing code.", "gridplus_check_device": "Check your Lattice1 device for the pairing secret.", "gridplus_choose_addresses": "Choose Addresses", - "gridplus_export_addresses": "Export Addresses", + "gridplus_export_addresses": "Import Addresses", + "available_addresses": "Available Addresses", "connect_ledger_title": "Connect your Ledger", "connect_ledger_description": "Connect your Ledger to Rainbow by plugging it into your computer using the USB-C cable.", "unlock_ledger_title": "Unlock Ledger", From 7e64b4ab18c0470f6d976615f438c09b9147a224 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Mon, 12 Feb 2024 15:39:53 +0100 Subject: [PATCH 16/30] chore(gridplus): handle case of removed permissions in Lattice --- src/entries/popup/App.tsx | 18 +++--- src/entries/popup/Routes.tsx | 57 +++++++++++++++++ src/entries/popup/handlers/gridplus.ts | 40 +++++++----- src/entries/popup/pages/hw/gridplus.tsx | 33 +--------- .../popup/pages/hw/gridplus/addressChoice.tsx | 25 ++++++-- .../pages/hw/gridplus/walletCredentials.tsx | 62 ++++++++++++------- static/json/languages/en_US.json | 4 +- 7 files changed, 158 insertions(+), 81 deletions(-) diff --git a/src/entries/popup/App.tsx b/src/entries/popup/App.tsx index 9bf9e8da79..81b536eab8 100644 --- a/src/entries/popup/App.tsx +++ b/src/entries/popup/App.tsx @@ -23,6 +23,10 @@ import { Routes } from './Routes'; import { HWRequestListener } from './components/HWRequestListener/HWRequestListener'; import { IdleTimer } from './components/IdleTimer/IdleTimer'; import { OnboardingKeepAlive } from './components/OnboardingKeepAlive'; +import { + getStoredGridPlusClient, + setStoredGridPlusClient, +} from './handlers/gridplus'; import { AuthProvider } from './hooks/useAuth'; import { useExpiryListener } from './hooks/useExpiryListener'; import { useIsFullScreen } from './hooks/useIsFullScreen'; @@ -34,13 +38,6 @@ import { RainbowConnector } from './wagmi/RainbowConnector'; const playground = process.env.PLAYGROUND as 'default' | 'ds'; const backgroundMessenger = initializeMessenger({ connect: 'background' }); -const getStoredClient = () => localStorage.getItem('storedClient') ?? ''; - -const setStoredClient = (storedClient: string | null) => { - if (!storedClient) return; - localStorage.setItem('storedClient', storedClient); -}; - export function App() { const { currentLanguage, setCurrentLanguage } = useCurrentLanguageStore(); const { deviceId } = useDeviceIdStore(); @@ -83,7 +80,12 @@ export function App() { analytics.track(event.popupOpened); setTimeout(() => flushQueuedEvents(), 1000); } - setup({ getStoredClient, setStoredClient, name: 'Rainbow' }); + if (getStoredGridPlusClient()) + setup({ + getStoredClient: getStoredGridPlusClient, + setStoredClient: setStoredGridPlusClient, + name: 'Rainbow', + }); // Init trezor once globally window.TrezorConnect?.init({ manifest: { diff --git a/src/entries/popup/Routes.tsx b/src/entries/popup/Routes.tsx index eb75c35688..0bbb6cc0c3 100644 --- a/src/entries/popup/Routes.tsx +++ b/src/entries/popup/Routes.tsx @@ -1,4 +1,5 @@ import { AnimatePresence } from 'framer-motion'; +import { connect } from 'gridplus-sdk'; import * as React from 'react'; import { Outlet, @@ -8,13 +9,18 @@ import { useLocation, useRouteError, } from 'react-router-dom'; +import { Address } from 'wagmi'; import { analytics } from '~/analytics'; import { screen } from '~/analytics/screen'; import { i18n } from '~/core/languages'; import { shortcuts } from '~/core/references/shortcuts'; +import { useCurrentAddressStore } from '~/core/state'; import { useErrorStore } from '~/core/state/error'; +import { useHiddenWalletsStore } from '~/core/state/hiddenWallets'; import { useNavRestorationStore } from '~/core/state/navRestoration'; +import { useWalletBackupsStore } from '~/core/state/walletBackups'; +import { useWalletNamesStore } from '~/core/state/walletNames'; import { POPUP_DIMENSIONS } from '~/core/utils/dimensions'; import { Box } from '~/design-system'; import { Alert } from '~/design-system/components/Alert/Alert'; @@ -30,6 +36,12 @@ import { ImportWalletViaSeed } from './components/ImportWallet/ImportWalletViaSe import { Toast } from './components/Toast/Toast'; import { UnsupportedBrowserSheet } from './components/UnsupportedBrowserSheet'; import { WindowStroke } from './components/WindowStroke/WindowStroke'; +import { + getStoredGridPlusClient, + removeStoredGridPlusClient, +} from './handlers/gridplus'; +import { remove, wipe } from './handlers/wallet'; +import { useAccounts } from './hooks/useAccounts'; import { useCommandKShortcuts } from './hooks/useCommandKShortcuts'; import useKeyboardAnalytics from './hooks/useKeyboardAnalytics'; import { useKeyboardShortcut } from './hooks/useKeyboardShortcut'; @@ -949,9 +961,22 @@ const ROUTE_DATA = [ ] satisfies RouteObject[]; const RootLayout = () => { + const navigate = useRainbowNavigate(); const { pathname, state } = useLocation(); const { setLastPage, setLastState, shouldRestoreNavigation } = useNavRestorationStore(); + 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 }); + }; React.useLayoutEffect(() => { window.scrollTo(0, 0); @@ -965,6 +990,38 @@ const RootLayout = () => { } }, [pathname, setLastPage, setLastState, shouldRestoreNavigation, state]); + // 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. + React.useEffect(() => { + if (sortedAccounts.length === 0) return; + if (getStoredGridPlusClient()) { + const deviceId = localStorage.getItem('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); + } + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sortedAccounts.length]); + useGlobalShortcuts(); useCommandKShortcuts(); diff --git a/src/entries/popup/handlers/gridplus.ts b/src/entries/popup/handlers/gridplus.ts index e3ca9ff0f4..b97e94557d 100644 --- a/src/entries/popup/handlers/gridplus.ts +++ b/src/entries/popup/handlers/gridplus.ts @@ -24,41 +24,51 @@ import { getPath } from '~/core/keychain'; import { LEGACY_CHAINS_FOR_HW } from '~/core/references'; import { addHexPrefix } from '~/core/utils/hex'; +const LOCAL_STORAGE_CLIENT_NAME = 'storedClient'; + +export const getStoredGridPlusClient = () => + localStorage.getItem(LOCAL_STORAGE_CLIENT_NAME) ?? ''; + +export const setStoredGridPlusClient = (storedClient: string | null) => { + if (!storedClient) return; + localStorage.setItem(LOCAL_STORAGE_CLIENT_NAME, storedClient); +}; + +export const removeStoredGridPlusClient = () => + localStorage.removeItem(LOCAL_STORAGE_CLIENT_NAME); + export async function signTransactionFromGridPlus( transaction: TransactionRequest, ) { try { const { from: address } = transaction; const baseTx: UnsignedTransaction = { - chainId: transaction.chainId || undefined, - data: transaction.data || undefined, + 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 || undefined, + to: transaction.to, value: transaction.value ? BigNumber.from(transaction.value).toHexString() : undefined, }; - let forceLegacy = false; - // HW doesn't support type 2 for these networks yet - if (LEGACY_CHAINS_FOR_HW.includes(transaction.chainId as ChainId)) { - forceLegacy = true; - } + const forceLegacy = LEGACY_CHAINS_FOR_HW.includes( + transaction.chainId as ChainId, + ); if (transaction.gasPrice) { baseTx.gasPrice = transaction.gasPrice; } else if (!forceLegacy) { - baseTx.maxFeePerGas = transaction.maxFeePerGas || undefined; - baseTx.maxPriorityFeePerGas = - transaction.maxPriorityFeePerGas || undefined; + baseTx.maxFeePerGas = transaction.maxFeePerGas; + baseTx.maxPriorityFeePerGas = transaction.maxPriorityFeePerGas; baseTx.type = 2; } else { - baseTx.gasPrice = transaction.maxFeePerGas || undefined; + baseTx.gasPrice = transaction.maxFeePerGas; } const common = new Common({ @@ -90,7 +100,9 @@ export async function signTransactionFromGridPlus( const parsedTx = parse(serializedTransaction); if (parsedTx.from?.toLowerCase() !== address?.toLowerCase()) { - throw new Error('Transaction was not signed by the right address'); + 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; @@ -194,7 +206,7 @@ export async function signMessageByTypeFromGridPlus( if (responseAddress.toLowerCase() !== address.toLowerCase()) { throw new Error( - 'GridPlus returned a different address than the one requested', + 'Address not found on this wallet. Try another SafeCard or remove the SafeCard to use the wallet on your device.', ); } diff --git a/src/entries/popup/pages/hw/gridplus.tsx b/src/entries/popup/pages/hw/gridplus.tsx index ffa694ede1..36169b48df 100644 --- a/src/entries/popup/pages/hw/gridplus.tsx +++ b/src/entries/popup/pages/hw/gridplus.tsx @@ -1,13 +1,10 @@ import { AnimatePresence } from 'framer-motion'; -import React, { useState } from 'react'; -import { useLocation } from 'react-router-dom'; +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 { useRainbowNavigate } from '../../hooks/useRainbowNavigate'; -import { ROUTES } from '../../urls'; import { AddressChoice } from './gridplus/addressChoice'; import { PairingSecret } from './gridplus/pairingSecret'; @@ -22,11 +19,9 @@ enum GridplusStep { const GridPlusRouting = ({ step, setStep, - onFinish, }: { step: GridplusStep; setStep: (step: GridplusStep) => void; - onFinish: (addresses: string[]) => void; }) => { switch (step) { case GridplusStep.WALLET_CREDENTIALS: @@ -48,34 +43,16 @@ const GridPlusRouting = ({ /> ); case GridplusStep.ADDRESS_CHOICE: - return ; + return ; default: return null; } }; export function ConnectGridPlus() { - const navigate = useRainbowNavigate(); - const { state } = useLocation(); const [gridplusStep, setGridplusStep] = useState( GridplusStep.WALLET_CREDENTIALS, ); - const onFinish = (addresses: string[]) => { - const accountsToImport = addresses.map((address, i) => ({ - address, - index: i, - })); - navigate(ROUTES.HW_WALLET_LIST, { - state: { - accountsToImport, - deviceId: 'GridPlus', - accountsEnabled: accountsToImport.length, - vendor: 'GridPlus', - direction: state?.direction, - navbarIcon: state?.navbarIcon, - }, - }); - }; return ( - + diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index f4192df9aa..19382e0875 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -2,6 +2,7 @@ import { getAddress } from '@ethersproject/address'; import { motion } from 'framer-motion'; import { fetchAddresses } from 'gridplus-sdk'; import { FormEvent, useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { Address } from 'wagmi'; import { i18n } from '~/core/languages'; @@ -11,16 +12,15 @@ import { Checkbox } from '~/entries/popup/components/Checkbox/Checkbox'; import { Link } from '~/entries/popup/components/Link/Link'; import { Spinner } from '~/entries/popup/components/Spinner/Spinner'; import { useAccounts } from '~/entries/popup/hooks/useAccounts'; +import { ROUTES } from '~/entries/popup/urls'; export type AddressesData = { addresses: Address[]; }; -export type AddressChoiceProps = { - onSelected: (addressses: AddressesData['addresses']) => void; -}; - -export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { +export const AddressChoice = () => { + const navigate = useNavigate(); + const { state } = useLocation(); const { sortedAccounts } = useAccounts(); const [formData, setFormData] = useState({ selectedAddresses: [] as string[], @@ -42,7 +42,20 @@ export const AddressChoice = ({ onSelected }: AddressChoiceProps) => { }; const onSubmit = (event: FormEvent) => { event.preventDefault(); - onSelected(formData.selectedAddresses as Address[]); + const accountsToImport = formData.selectedAddresses.map((address, i) => ({ + address, + index: i, + })); + navigate(ROUTES.HW_WALLET_LIST, { + state: { + accountsToImport, + deviceId: 'GridPlus', + accountsEnabled: accountsToImport.length, + vendor: 'GridPlus', + direction: state?.direction, + navbarIcon: state?.navbarIcon, + }, + }); }; useEffect(() => { const fetchWalletAddresses = async () => { diff --git a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx index 37a79d7fde..ea2dd9ec5f 100644 --- a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -6,6 +6,10 @@ 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'; +import { + getStoredGridPlusClient, + setStoredGridPlusClient, +} from '~/entries/popup/handlers/gridplus'; export type WalletCredentialsProps = { appName: string; @@ -24,12 +28,6 @@ export const WalletCredentials = ({ const formDataFilled = formData.deviceId.length > 0 && formData.password.length > 0; const disabled = !formDataFilled || connecting; - const getStoredClient = () => localStorage.getItem('storedClient') ?? ''; - - const setStoredClient = (storedClient: string | null) => { - if (!storedClient) return; - localStorage.setItem('storedClient', storedClient); - }; const onSubmit = async (event: FormEvent) => { event.preventDefault(); setConnecting(true); @@ -38,9 +36,10 @@ export const WalletCredentials = ({ deviceId: formData.deviceId, password: formData.password, name: appName, - getStoredClient, - setStoredClient, + getStoredClient: getStoredGridPlusClient, + setStoredClient: setStoredGridPlusClient, }); + localStorage.setItem('gridPlusDeviceId', formData.deviceId); onAfterSetup && onAfterSetup(result); } finally { setConnecting(false); @@ -48,10 +47,10 @@ export const WalletCredentials = ({ }; useEffect(() => { const checkPersistedClient = async () => { - if (getStoredClient()) { + if (getStoredGridPlusClient()) { const result = await setup({ - getStoredClient, - setStoredClient, + getStoredClient: getStoredGridPlusClient, + setStoredClient: setStoredGridPlusClient, name: appName, }); onAfterSetup && onAfterSetup(result); @@ -73,20 +72,44 @@ export const WalletCredentials = ({ {i18n.t('hw.connect_gridplus_title')} - - {i18n.t('hw.connect_gridplus_description')} - - - - {i18n.t('hw.gridplus_device_id')} + + + {i18n.t('hw.connect_gridplus_description')} + + + + + {i18n.t('hw.gridplus_device_id')} + + + {i18n.t('hw.gridplus_device_id_description')} + + - - {i18n.t('hw.gridplus_device_id_description')} - diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index 54165b19c9..f991622812 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -1542,7 +1542,7 @@ "trezor_support": "Supports Model One or Model T devices.", "gridplus_support": "Supports the GridPlus Lattice1 and SafeCards.", "gridplus_device_id": "Device ID", - "gridplus_device_id_description": "Get your Device ID from Lattice Home.", + "gridplus_device_id_description": "Located on device's home screen.", "gridplus_connect": "Connect", "gridplus_password": "GridPlus Password", "gridplus_password_create": "If this is your first time logging in, create a new password.", @@ -1567,7 +1567,7 @@ "connect_trezor_title": "Complete your Trezor set up", "connect_gridplus_title": "Connect your Lattice1", "connect_trezor_description": "Continue to connect your Trezor to Rainbow through the web interface.", - "connect_gridplus_description": "Connect your Lattice1 to Rainbow through the connection wizard.", + "connect_gridplus_description": "Connect your Lattice1 to Rainbow using the Connection Wizard.", "learn_more": "Learn more.", "connect_wallets_title": "Connect your wallets", "connect_wallets_found": "We’ve found %{count} wallets on your %{vendor} with a balance or activity. Select which to connect.", From 520ce666e35c7fd4f6aa04ae305d539b20657858 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Wed, 28 Feb 2024 15:35:51 +0100 Subject: [PATCH 17/30] fix(gridplus): pr improvements --- e2e/helpers.ts | 4 +- e2e/parallel/GridPlusImportFlow.test.ts | 3 +- lavamoat/build-webpack/policy.json | 156 +++++++++--------- src/core/state/gridplusClient/index.ts | 23 +++ src/design-system/components/Text/Text.tsx | 16 +- .../components/TextOverflow/TextOverflow.tsx | 13 +- src/entries/popup/App.tsx | 13 +- src/entries/popup/Routes.tsx | 59 +------ src/entries/popup/handlers/gridplus.ts | 9 +- src/entries/popup/handlers/gridplusHooks.tsx | 90 ++++++++++ src/entries/popup/handlers/wallet.ts | 20 ++- src/entries/popup/index.html | 24 +-- .../popup/pages/hw/gridplus/addressChoice.tsx | 8 +- .../pages/hw/gridplus/walletCredentials.tsx | 19 ++- static/manifest.json | 38 ++++- 15 files changed, 278 insertions(+), 217 deletions(-) create mode 100644 src/core/state/gridplusClient/index.ts create mode 100644 src/entries/popup/handlers/gridplusHooks.tsx diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 9e0518f021..b09eb2c7e9 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -848,7 +848,7 @@ export async function importGridPlusWallet(driver: WebDriver, rootURL: string) { driver, }); inputPairingCode.click(); - await delayTime('unbelievably-long'); + await delay(15000); await findElementByTestIdAndClick({ id: 'gridplus-submit', driver, @@ -1141,8 +1141,6 @@ export async function delayTime( return await delay(1000); case 'very-long': return await delay(5000); - case 'unbelievably-long': - return await delay(15000); } } diff --git a/e2e/parallel/GridPlusImportFlow.test.ts b/e2e/parallel/GridPlusImportFlow.test.ts index c7f1e0eef7..1d869e5121 100644 --- a/e2e/parallel/GridPlusImportFlow.test.ts +++ b/e2e/parallel/GridPlusImportFlow.test.ts @@ -31,7 +31,8 @@ describe.runIf(browser !== 'firefox')( afterAll(async () => driver.quit()); it('should be able import a wallet via hw wallet', async () => { - await importGridPlusWallet(driver, rootURL); + if (process.env.IS_TESTING === 'true') + await importGridPlusWallet(driver, rootURL); }); }, ); diff --git a/lavamoat/build-webpack/policy.json b/lavamoat/build-webpack/policy.json index 900b34331d..8cc27c9697 100644 --- a/lavamoat/build-webpack/policy.json +++ b/lavamoat/build-webpack/policy.json @@ -1115,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": { @@ -1188,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": { 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/design-system/components/Text/Text.tsx b/src/design-system/components/Text/Text.tsx index 0f1032da9b..dea3afffc3 100644 --- a/src/design-system/components/Text/Text.tsx +++ b/src/design-system/components/Text/Text.tsx @@ -8,18 +8,7 @@ import { selectionStyle } from './Text.css'; export interface TextProps { align?: TextStyles['textAlign']; - as?: - | 'div' - | 'p' - | 'span' - | 'h1' - | 'h2' - | 'h3' - | 'h4' - | 'h5' - | 'h6' - | 'pre' - | 'label'; + as?: 'div' | 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'pre'; children: React.ReactNode; color?: TextStyles['color']; size: TextStyles['fontSize']; @@ -32,7 +21,6 @@ export interface TextProps { whiteSpace?: TextStyles['whiteSpace']; textShadow?: TextStyles['textShadow']; fontFamily?: TextStyles['fontFamily']; - htmlFor?: string; } export function Text({ @@ -50,7 +38,6 @@ export function Text({ whiteSpace, textShadow, fontFamily = 'rounded', - htmlFor, }: TextProps) { return ( diff --git a/src/design-system/components/TextOverflow/TextOverflow.tsx b/src/design-system/components/TextOverflow/TextOverflow.tsx index dda8b6a621..d460296f20 100644 --- a/src/design-system/components/TextOverflow/TextOverflow.tsx +++ b/src/design-system/components/TextOverflow/TextOverflow.tsx @@ -6,18 +6,7 @@ import { Inset } from '../Inset/Inset'; interface TextOverflowProps { align?: TextStyles['textAlign']; - as?: - | 'div' - | 'p' - | 'span' - | 'h1' - | 'h2' - | 'h3' - | 'h4' - | 'h5' - | 'h6' - | 'pre' - | 'label'; + as?: 'div' | 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'pre'; children: React.ReactNode; color?: TextStyles['color']; size: TextStyles['fontSize']; diff --git a/src/entries/popup/App.tsx b/src/entries/popup/App.tsx index 81b536eab8..5b5d86d4cc 100644 --- a/src/entries/popup/App.tsx +++ b/src/entries/popup/App.tsx @@ -1,5 +1,4 @@ import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; -import { setup } from 'gridplus-sdk'; import { isEqual } from 'lodash'; import * as React from 'react'; import { WagmiConfig } from 'wagmi'; @@ -23,10 +22,7 @@ import { Routes } from './Routes'; import { HWRequestListener } from './components/HWRequestListener/HWRequestListener'; import { IdleTimer } from './components/IdleTimer/IdleTimer'; import { OnboardingKeepAlive } from './components/OnboardingKeepAlive'; -import { - getStoredGridPlusClient, - setStoredGridPlusClient, -} from './handlers/gridplus'; +import { useGridPlusInit } from './handlers/gridplusHooks'; import { AuthProvider } from './hooks/useAuth'; import { useExpiryListener } from './hooks/useExpiryListener'; import { useIsFullScreen } from './hooks/useIsFullScreen'; @@ -45,6 +41,7 @@ export function App() { const prevChains = usePrevious(rainbowChains); useExpiryListener(); + useGridPlusInit(); React.useEffect(() => { if (!isEqual(prevChains, rainbowChains)) { @@ -80,12 +77,6 @@ export function App() { analytics.track(event.popupOpened); setTimeout(() => flushQueuedEvents(), 1000); } - if (getStoredGridPlusClient()) - setup({ - getStoredClient: getStoredGridPlusClient, - setStoredClient: setStoredGridPlusClient, - name: 'Rainbow', - }); // Init trezor once globally window.TrezorConnect?.init({ manifest: { diff --git a/src/entries/popup/Routes.tsx b/src/entries/popup/Routes.tsx index b09a9a5d26..7e6b221821 100644 --- a/src/entries/popup/Routes.tsx +++ b/src/entries/popup/Routes.tsx @@ -1,5 +1,4 @@ import { AnimatePresence } from 'framer-motion'; -import { connect } from 'gridplus-sdk'; import * as React from 'react'; import { Outlet, @@ -9,18 +8,13 @@ import { useLocation, useRouteError, } from 'react-router-dom'; -import { Address } from 'wagmi'; import { analytics } from '~/analytics'; import { screen } from '~/analytics/screen'; import { i18n } from '~/core/languages'; import { shortcuts } from '~/core/references/shortcuts'; -import { useCurrentAddressStore } from '~/core/state'; import { useErrorStore } from '~/core/state/error'; -import { useHiddenWalletsStore } from '~/core/state/hiddenWallets'; import { useNavRestorationStore } from '~/core/state/navRestoration'; -import { useWalletBackupsStore } from '~/core/state/walletBackups'; -import { useWalletNamesStore } from '~/core/state/walletNames'; import { POPUP_DIMENSIONS } from '~/core/utils/dimensions'; import { Box } from '~/design-system'; import { Alert } from '~/design-system/components/Alert/Alert'; @@ -36,12 +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 { - getStoredGridPlusClient, - removeStoredGridPlusClient, -} from './handlers/gridplus'; -import { remove, wipe } from './handlers/wallet'; -import { useAccounts } from './hooks/useAccounts'; +import { useGridPlusPermissions } from './handlers/gridplusHooks'; import { useCommandKShortcuts } from './hooks/useCommandKShortcuts'; import useKeyboardAnalytics from './hooks/useKeyboardAnalytics'; import { useKeyboardShortcut } from './hooks/useKeyboardShortcut'; @@ -977,22 +966,9 @@ const ROUTE_DATA = [ ] satisfies RouteObject[]; const RootLayout = () => { - const navigate = useRainbowNavigate(); const { pathname, state } = useLocation(); const { setLastPage, setLastState, shouldRestoreNavigation } = useNavRestorationStore(); - 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 }); - }; React.useLayoutEffect(() => { window.scrollTo(0, 0); @@ -1006,40 +982,9 @@ const RootLayout = () => { } }, [pathname, setLastPage, setLastState, shouldRestoreNavigation, state]); - // 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. - React.useEffect(() => { - if (sortedAccounts.length === 0) return; - if (getStoredGridPlusClient()) { - const deviceId = localStorage.getItem('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); - } - } - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sortedAccounts.length]); - useGlobalShortcuts(); useCommandKShortcuts(); + useGridPlusPermissions(); return ( diff --git a/src/entries/popup/handlers/gridplus.ts b/src/entries/popup/handlers/gridplus.ts index b97e94557d..283d2bc4ef 100644 --- a/src/entries/popup/handlers/gridplus.ts +++ b/src/entries/popup/handlers/gridplus.ts @@ -22,20 +22,21 @@ import { Address } from 'wagmi'; import { getPath } from '~/core/keychain'; import { LEGACY_CHAINS_FOR_HW } from '~/core/references'; +import { LocalStorage } from '~/core/storage'; import { addHexPrefix } from '~/core/utils/hex'; const LOCAL_STORAGE_CLIENT_NAME = 'storedClient'; export const getStoredGridPlusClient = () => - localStorage.getItem(LOCAL_STORAGE_CLIENT_NAME) ?? ''; + LocalStorage.get(LOCAL_STORAGE_CLIENT_NAME) ?? ''; export const setStoredGridPlusClient = (storedClient: string | null) => { if (!storedClient) return; - localStorage.setItem(LOCAL_STORAGE_CLIENT_NAME, storedClient); + LocalStorage.set(LOCAL_STORAGE_CLIENT_NAME, storedClient); }; export const removeStoredGridPlusClient = () => - localStorage.removeItem(LOCAL_STORAGE_CLIENT_NAME); + LocalStorage.remove(LOCAL_STORAGE_CLIENT_NAME); export async function signTransactionFromGridPlus( transaction: TransactionRequest, @@ -107,14 +108,12 @@ export async function signTransactionFromGridPlus( return serializedTransaction; } else { - console.log('gridplus error', JSON.stringify(response, null, 2), baseTx); 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) { - console.log('gridplus error', e); alert('Please make sure your gridplus is unlocked'); // bubble up the error 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 de87d2b509..821f47bc99 100644 --- a/src/entries/popup/handlers/wallet.ts +++ b/src/entries/popup/handlers/wallet.ts @@ -381,12 +381,20 @@ export const importAccountAtIndex = async ( break; } case 'GridPlus': { - address = ( - await fetchAddresses({ - n: 1, - startPath: [0x80000000 + 44, 0x80000000 + 60, 0x80000000, 0, index], - }) - )[0]; + 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: diff --git a/src/entries/popup/index.html b/src/entries/popup/index.html index 24f343a7ee..ffed3d2728 100644 --- a/src/entries/popup/index.html +++ b/src/entries/popup/index.html @@ -1,13 +1,15 @@ - + - - - - Rainbow Wallet - - - -
- - + + + + Rainbow Wallet + + + + +
+ + + \ No newline at end of file diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index 19382e0875..b90ca6e990 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -6,6 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { Address } from 'wagmi'; import { i18n } from '~/core/languages'; +import { SessionStorage } from '~/core/storage'; import { truncateAddress } from '~/core/utils/address'; import { Box, Button, Text } from '~/design-system'; import { Checkbox } from '~/entries/popup/components/Checkbox/Checkbox'; @@ -70,9 +71,9 @@ export const AddressChoice = () => { setAddresses(nonExistingAddresses); setLoadingAddresses(false); }; - const setPersistedFormData = () => { + const setPersistedFormData = async () => { const persistedAddresses = JSON.parse( - sessionStorage.getItem('gridplusPersistedAddresses') ?? '[]', + (await SessionStorage.get('gridplusPersistedAddresses')) ?? '[]', ) as string[]; if (persistedAddresses.length < 1) return; setFormData({ selectedAddresses: persistedAddresses }); @@ -82,7 +83,7 @@ export const AddressChoice = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { - sessionStorage.setItem( + SessionStorage.set( 'gridplusPersistedAddresses', JSON.stringify(formData.selectedAddresses), ); @@ -117,7 +118,6 @@ export const AddressChoice = () => { {addresses.map((address, i) => ( toggleAddress(address)} selected={formData.selectedAddresses.includes(address)} diff --git a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx index ea2dd9ec5f..b4351dde77 100644 --- a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -3,6 +3,8 @@ import { setup } from 'gridplus-sdk'; 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'; @@ -20,6 +22,7 @@ export const WalletCredentials = ({ appName, onAfterSetup, }: WalletCredentialsProps) => { + const setClient = useGridPlusClientStore((state) => state.setClient); const [connecting, setConnecting] = useState(false); const [formData, setFormData] = useState({ deviceId: '', @@ -28,6 +31,11 @@ export const WalletCredentials = ({ const formDataFilled = formData.deviceId.length > 0 && formData.password.length > 0; const disabled = !formDataFilled || connecting; + const setStoredClient = (storedClient: string | null) => { + if (!storedClient) return; + setStoredGridPlusClient(storedClient); + setClient(storedClient); + }; const onSubmit = async (event: FormEvent) => { event.preventDefault(); setConnecting(true); @@ -36,10 +44,10 @@ export const WalletCredentials = ({ deviceId: formData.deviceId, password: formData.password, name: appName, - getStoredClient: getStoredGridPlusClient, - setStoredClient: setStoredGridPlusClient, + getStoredClient: () => useGridPlusClientStore.getState().client, + setStoredClient: setStoredClient, }); - localStorage.setItem('gridPlusDeviceId', formData.deviceId); + await LocalStorage.set('gridPlusDeviceId', formData.deviceId); onAfterSetup && onAfterSetup(result); } finally { setConnecting(false); @@ -47,9 +55,10 @@ export const WalletCredentials = ({ }; useEffect(() => { const checkPersistedClient = async () => { - if (getStoredGridPlusClient()) { + const gridPlusClient = await getStoredGridPlusClient(); + if (gridPlusClient) { const result = await setup({ - getStoredClient: getStoredGridPlusClient, + getStoredClient: () => gridPlusClient, setStoredClient: setStoredGridPlusClient, name: appName, }); diff --git a/static/manifest.json b/static/manifest.json index 7709988441..884c77e480 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -18,21 +18,34 @@ "content_scripts": [ { "all_frames": true, - "js": ["contentscript.js"], - "matches": ["http://*/*", "https://*/*"], + "js": [ + "contentscript.js" + ], + "matches": [ + "http://*/*", + "https://*/*" + ], "run_at": "document_start" }, { - "matches": ["*://connect.trezor.io/9/popup.html"], - "js": ["./vendor/trezor-content-script.js"] + "matches": [ + "*://connect.trezor.io/9/popup.html" + ], + "js": [ + "./vendor/trezor-content-script.js" + ] } ], "content_security_policy": { - "extension_pages": "frame-ancestors 'none'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self'" + "extension_pages": "frame-ancestors 'none'; script-src 'self'; object-src 'self'; connect-src 'self'" }, "default_locale": "en_US", "description": "DEV VERSION", - "host_permissions": ["http://*/*", "https://*/*", "wss://*/*"], + "host_permissions": [ + "http://*/*", + "https://*/*", + "wss://*/*" + ], "icons": { "16": "images/icon-16.png", "19": "images/icon-19.png", @@ -58,8 +71,15 @@ "version": "1.4.6", "web_accessible_resources": [ { - "matches": [""], - "resources": ["inpage.js", "*.woff2", "popup.css", "assets/badges/*.png"] + "matches": [ + "" + ], + "resources": [ + "inpage.js", + "*.woff2", + "popup.css", + "assets/badges/*.png" + ] } ], "commands": { @@ -73,4 +93,4 @@ "description": "Open the Rainbow Wallet extension" } } -} +} \ No newline at end of file From 2c8759c3dca152ef10a229a216b73ff6109dafa8 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Wed, 28 Feb 2024 15:55:22 +0100 Subject: [PATCH 18/30] fix(gridplus): revert trezor vendor lib linting changes --- static/vendor/trezor-connect.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/static/vendor/trezor-connect.js b/static/vendor/trezor-connect.js index 7d4911bb02..2db1da3562 100644 --- a/static/vendor/trezor-connect.js +++ b/static/vendor/trezor-connect.js @@ -20168,7 +20168,7 @@ function __classPrivateFieldIn(state, receiver) { /************************************************************************/ /******/ // The module cache /******/ var __webpack_module_cache__ = {}; -/******/ +/******/ /******/ // The require function /******/ function __webpack_require__(moduleId) { /******/ // Check if module is in cache @@ -20182,14 +20182,14 @@ function __classPrivateFieldIn(state, receiver) { /******/ // no module.loaded needed /******/ exports: {} /******/ }; -/******/ +/******/ /******/ // Execute the module function /******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__); -/******/ +/******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } -/******/ +/******/ /************************************************************************/ /******/ /* webpack/runtime/compat get default export */ /******/ (() => { @@ -20202,7 +20202,7 @@ function __classPrivateFieldIn(state, receiver) { /******/ return getter; /******/ }; /******/ })(); -/******/ +/******/ /******/ /* webpack/runtime/define property getters */ /******/ (() => { /******/ // define getter functions for harmony exports @@ -20214,7 +20214,7 @@ function __classPrivateFieldIn(state, receiver) { /******/ } /******/ }; /******/ })(); -/******/ +/******/ /******/ /* webpack/runtime/global */ /******/ (() => { /******/ __webpack_require__.g = (function() { @@ -20226,12 +20226,12 @@ function __classPrivateFieldIn(state, receiver) { /******/ } /******/ })(); /******/ })(); -/******/ +/******/ /******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ (() => { /******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) /******/ })(); -/******/ +/******/ /******/ /* webpack/runtime/make namespace object */ /******/ (() => { /******/ // define __esModule on exports @@ -20242,7 +20242,7 @@ function __classPrivateFieldIn(state, receiver) { /******/ Object.defineProperty(exports, '__esModule', { value: true }); /******/ }; /******/ })(); -/******/ +/******/ /************************************************************************/ var __webpack_exports__ = {}; // This entry need to be wrapped in an IIFE because it need to be in strict mode. From c0a489de15cae7b53d64fd15a8c970b448647d32 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Wed, 28 Feb 2024 16:07:39 +0100 Subject: [PATCH 19/30] fix(gridplus): revert checkbox changes --- src/entries/popup/components/Checkbox/Checkbox.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/entries/popup/components/Checkbox/Checkbox.tsx b/src/entries/popup/components/Checkbox/Checkbox.tsx index 66264a8bb9..7763eff5d4 100644 --- a/src/entries/popup/components/Checkbox/Checkbox.tsx +++ b/src/entries/popup/components/Checkbox/Checkbox.tsx @@ -15,7 +15,6 @@ export function Checkbox({ borderColor = 'separatorSecondary', borderColorSelected = 'accent', testId, - id, }: { selected: boolean; onClick?: () => void; @@ -27,11 +26,9 @@ export function Checkbox({ background?: 'accent' | BackgroundColor; backgroundSelected?: 'accent' | BackgroundColor; testId?: string; - id?: string; }) { return ( Date: Thu, 29 Feb 2024 15:25:46 +0100 Subject: [PATCH 20/30] chore(gridplus): add onboarding e2e flow mocks --- e2e/helpers.ts | 18 ++---------------- e2e/parallel/GridPlusImportFlow.test.ts | 3 +-- .../popup/pages/hw/gridplus/addressChoice.tsx | 10 +++++++++- .../pages/hw/gridplus/walletCredentials.tsx | 19 ++++++++++++------- 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index b09eb2c7e9..5d20835073 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -812,9 +812,6 @@ export async function importHardwareWalletFlow( } export async function importGridPlusWallet(driver: WebDriver, rootURL: string) { - const { env } = import.meta as unknown as { - env: { GRIDPLUS_DEVICE_ID: string; GRIDPLUS_DEVICE_PASSWORD: string }; - }; await goToWelcome(driver, rootURL); await findElementByTestIdAndClick({ id: 'import-wallet-button', @@ -832,28 +829,17 @@ export async function importGridPlusWallet(driver: WebDriver, rootURL: string) { id: 'gridplus-deviceid', driver, }); - await inputDeviceId.sendKeys(env.GRIDPLUS_DEVICE_ID); + await inputDeviceId.sendKeys('MOCKED_DEVICE_ID'); const inputPassword = await findElementByTestId({ id: 'gridplus-password', driver, }); - await inputPassword.sendKeys(env.GRIDPLUS_DEVICE_PASSWORD); + await inputPassword.sendKeys('MOCKED_PASSWORD'); await delayTime('long'); await findElementByTestIdAndClick({ id: 'gridplus-submit', driver, }); - const inputPairingCode = await findElementByTestId({ - id: 'gridplus-pairing-code', - driver, - }); - inputPairingCode.click(); - await delay(15000); - await findElementByTestIdAndClick({ - id: 'gridplus-submit', - driver, - }); - await delayTime('very-long'); await findElementByTestIdAndClick({ id: 'gridplus-address-0', driver, diff --git a/e2e/parallel/GridPlusImportFlow.test.ts b/e2e/parallel/GridPlusImportFlow.test.ts index 1d869e5121..c7f1e0eef7 100644 --- a/e2e/parallel/GridPlusImportFlow.test.ts +++ b/e2e/parallel/GridPlusImportFlow.test.ts @@ -31,8 +31,7 @@ describe.runIf(browser !== 'firefox')( afterAll(async () => driver.quit()); it('should be able import a wallet via hw wallet', async () => { - if (process.env.IS_TESTING === 'true') - await importGridPlusWallet(driver, rootURL); + await importGridPlusWallet(driver, rootURL); }); }, ); diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index b90ca6e990..286ab46e4e 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -12,6 +12,7 @@ import { Box, Button, Text } from '~/design-system'; import { Checkbox } from '~/entries/popup/components/Checkbox/Checkbox'; import { Link } from '~/entries/popup/components/Link/Link'; import { Spinner } from '~/entries/popup/components/Spinner/Spinner'; +import { HARDWARE_WALLETS } from '~/entries/popup/handlers/walletVariables'; import { useAccounts } from '~/entries/popup/hooks/useAccounts'; import { ROUTES } from '~/entries/popup/urls'; @@ -61,7 +62,14 @@ export const AddressChoice = () => { useEffect(() => { const fetchWalletAddresses = async () => { setLoadingAddresses(true); - const fetchedAddresses = (await fetchAddresses()) as Address[]; + let fetchedAddresses: Address[]; + if (process.env.IS_TESTING === 'true') { + fetchedAddresses = HARDWARE_WALLETS.MOCK_ACCOUNT.accountsToImport.map( + (account) => account.address, + ); + } else { + fetchedAddresses = (await fetchAddresses()) as Address[]; + } const nonExistingAddresses = fetchedAddresses .map((address) => getAddress(address)) .filter( diff --git a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx index b4351dde77..a83bea80a5 100644 --- a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -40,13 +40,18 @@ export const WalletCredentials = ({ event.preventDefault(); setConnecting(true); try { - const result = await setup({ - deviceId: formData.deviceId, - password: formData.password, - name: appName, - getStoredClient: () => useGridPlusClientStore.getState().client, - setStoredClient: setStoredClient, - }); + let result: boolean; + if (process.env.IS_TESTING === 'true') { + result = true; + } else { + result = await setup({ + deviceId: formData.deviceId, + password: formData.password, + name: appName, + getStoredClient: () => useGridPlusClientStore.getState().client, + setStoredClient: setStoredClient, + }); + } await LocalStorage.set('gridPlusDeviceId', formData.deviceId); onAfterSetup && onAfterSetup(result); } finally { From fe5d6e7aee0301dfb15312dffbede2826628e923 Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Mon, 11 Mar 2024 15:14:53 +0100 Subject: [PATCH 21/30] fix(gridplus): apply pr feedback --- e2e/helpers.ts | 2 +- package.json | 1 - .../popup/pages/hw/gridplus/addressChoice.tsx | 1 + .../popup/pages/hw/gridplus/pairingSecret.tsx | 2 ++ .../pages/hw/gridplus/walletCredentials.tsx | 3 +++ .../walletsAndKeys/walletsAndKeys.tsx | 19 ++++++++++++++----- 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 5d20835073..e7e34fac58 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1116,7 +1116,7 @@ export async function delay(ms: number) { } export async function delayTime( - time: 'short' | 'medium' | 'long' | 'very-long' | 'unbelievably-long', + time: 'short' | 'medium' | 'long' | 'very-long', ) { switch (time) { case 'short': diff --git a/package.json b/package.json index e29a5f1652..d97a952ed6 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ }, "dependencies": { "@capsizecss/core": "3.0.0", - "@ethereumjs/common": "4.1.0", "@ethereumjs/tx": "5.0.0", "@ethereumjs/util": "9.0.0", "@ethersproject/abstract-signer": "5.7.0", diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index 286ab46e4e..d6babfc51c 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -151,6 +151,7 @@ export const AddressChoice = () => { width="full" height="44px" symbol="checkmark.circle.fill" + tabIndex={0} > {i18n.t('hw.gridplus_export_addresses')} diff --git a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx index 29de0d41f0..b719882c44 100644 --- a/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx +++ b/src/entries/popup/pages/hw/gridplus/pairingSecret.tsx @@ -75,6 +75,7 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { } value={formData.pairingCode} testId="gridplus-pairing-code" + tabIndex={0} autoFocus /> {formState.error && ( @@ -92,6 +93,7 @@ export const PairingSecret = ({ onAfterPair }: PairingSecretProps) => { disabled={disabled} width="full" symbol="checkmark.circle.fill" + tabIndex={0} > {pairing ? ( diff --git a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx index a83bea80a5..8b2e646be5 100644 --- a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -135,6 +135,7 @@ export const WalletCredentials = ({ value={formData.deviceId} testId="gridplus-deviceid" aria-label="username" + tabIndex={0} /> @@ -153,6 +154,7 @@ export const WalletCredentials = ({ value={formData.password} testId="gridplus-password" aria-label="password" + tabIndex={0} /> {i18n.t('hw.gridplus_password_create')} @@ -167,6 +169,7 @@ export const WalletCredentials = ({ testId="gridplus-submit" width="full" symbol="checkmark.circle.fill" + tabIndex={0} > {connecting ? ( diff --git a/src/entries/popup/pages/settings/walletsAndKeys/walletsAndKeys.tsx b/src/entries/popup/pages/settings/walletsAndKeys/walletsAndKeys.tsx index deecad5823..a19af49413 100644 --- a/src/entries/popup/pages/settings/walletsAndKeys/walletsAndKeys.tsx +++ b/src/entries/popup/pages/settings/walletsAndKeys/walletsAndKeys.tsx @@ -9,6 +9,7 @@ import { import { useLocation } from 'react-router'; import { Address } from 'wagmi'; +import gridPlusLogo from 'static/assets/hw/grid-plus-logo.png'; import { i18n } from '~/core/languages'; import { useHiddenWalletsStore } from '~/core/state/hiddenWallets'; import { useWalletBackupsStore } from '~/core/state/walletBackups'; @@ -132,6 +133,18 @@ function WalletsAndKeysContextMenu({ ); } +const WalletIcon = ({ vendor }: { vendor: KeychainWallet['vendor'] }) => { + switch (vendor) { + case 'Trezor': + return ; + case 'GridPlus': + return ; + case 'Ledger': + default: + return ; + } +}; + export const WalletsAndKeys = () => { const navigate = useRainbowNavigate(); const [wallets, setWallets] = useState([]); @@ -294,11 +307,7 @@ export const WalletsAndKeys = () => { onClick={() => handleViewWallet({ wallet })} leftComponent={ wallet.type === KeychainType.HardwareWalletKeychain ? ( - wallet.vendor === 'Trezor' ? ( - - ) : ( - - ) + ) : ( Date: Mon, 15 Apr 2024 15:37:36 +0200 Subject: [PATCH 22/30] fix(gridplus): fix L2 transactions --- src/entries/popup/handlers/gridplus.ts | 57 +++++++++++++++++--------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/src/entries/popup/handlers/gridplus.ts b/src/entries/popup/handlers/gridplus.ts index 283d2bc4ef..1fe5497251 100644 --- a/src/entries/popup/handlers/gridplus.ts +++ b/src/entries/popup/handlers/gridplus.ts @@ -1,4 +1,4 @@ -import { Chain, Common, Hardfork } from '@ethereumjs/common'; +import { Common, Hardfork } from '@ethereumjs/common'; import { TransactionFactory, TypedTxData } from '@ethereumjs/tx'; import { TransactionRequest, @@ -12,16 +12,16 @@ import { serialize, } from '@ethersproject/transactions'; import { TypedDataUtils } from '@metamask/eth-sig-util'; -import { ChainId } from '@rainbow-me/swaps'; 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 { LEGACY_CHAINS_FOR_HW } from '~/core/references'; import { LocalStorage } from '~/core/storage'; import { addHexPrefix } from '~/core/utils/hex'; @@ -43,6 +43,15 @@ export async function signTransactionFromGridPlus( ) { 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, @@ -58,30 +67,42 @@ export async function signTransactionFromGridPlus( : undefined, }; - const forceLegacy = LEGACY_CHAINS_FOR_HW.includes( - transaction.chainId as ChainId, - ); - if (transaction.gasPrice) { baseTx.gasPrice = transaction.gasPrice; - } else if (!forceLegacy) { - baseTx.maxFeePerGas = transaction.maxFeePerGas; - baseTx.maxPriorityFeePerGas = transaction.maxPriorityFeePerGas; - baseTx.type = 2; } else { - baseTx.gasPrice = transaction.maxFeePerGas; + if (transaction.maxFeePerGas && transaction.maxPriorityFeePerGas) { + baseTx.maxFeePerGas = transaction.maxFeePerGas; + baseTx.maxPriorityFeePerGas = transaction.maxPriorityFeePerGas; + baseTx.type = 2; + } else { + baseTx.gasPrice = transaction.maxFeePerGas; + } } - const common = new Common({ - chain: Chain.Mainnet, - hardfork: Hardfork.London, + const common = Common.custom({ + chainId: transaction.chainId, + defaultHardfork: Hardfork.London, }); const txPayload = TransactionFactory.fromTxData(baseTx as TypedTxData, { common, }); - const response = await gridPlusSign(txPayload.getMessageToSign() as Buffer); + 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')); @@ -90,9 +111,6 @@ export async function signTransactionFromGridPlus( ).toNumber(); if (response.pubkey) { - if (baseTx.gasLimit) { - baseTx.type = 2; - } const serializedTransaction = serialize(baseTx, { r, s, @@ -115,7 +133,6 @@ export async function signTransactionFromGridPlus( // 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; } From 7b47ed3dacaa2f05c2f8d2c979be7928ae0ec71e Mon Sep 17 00:00:00 2001 From: Tomek Marciniak Date: Mon, 15 Apr 2024 15:52:39 +0200 Subject: [PATCH 23/30] chore(gridplus): update logo in wallets & keys --- .../pages/settings/walletsAndKeys/walletsAndKeys.tsx | 4 ++-- static/assets/hw/grid-plus-circle.svg | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 static/assets/hw/grid-plus-circle.svg diff --git a/src/entries/popup/pages/settings/walletsAndKeys/walletsAndKeys.tsx b/src/entries/popup/pages/settings/walletsAndKeys/walletsAndKeys.tsx index 47d9b4aee5..fd3d8cea39 100644 --- a/src/entries/popup/pages/settings/walletsAndKeys/walletsAndKeys.tsx +++ b/src/entries/popup/pages/settings/walletsAndKeys/walletsAndKeys.tsx @@ -9,7 +9,7 @@ import { import { useLocation } from 'react-router'; import { Address } from 'wagmi'; -import gridPlusLogo from 'static/assets/hw/grid-plus-logo.png'; +import gridPlusLogo from 'static/assets/hw/grid-plus-circle.svg'; import { i18n } from '~/core/languages'; import { useHiddenWalletsStore } from '~/core/state/hiddenWallets'; import { useWalletBackupsStore } from '~/core/state/walletBackups'; @@ -140,7 +140,7 @@ const WalletIcon = ({ vendor }: { vendor: KeychainWallet['vendor'] }) => { case 'Trezor': return ; case 'GridPlus': - return ; + return ; case 'Ledger': default: return ; diff --git a/static/assets/hw/grid-plus-circle.svg b/static/assets/hw/grid-plus-circle.svg new file mode 100644 index 0000000000..bf990ff969 --- /dev/null +++ b/static/assets/hw/grid-plus-circle.svg @@ -0,0 +1,8 @@ + + + + + + + + From 03accfdd50fd91c4e035b2066f76fe7f30347ab6 Mon Sep 17 00:00:00 2001 From: gregs Date: Mon, 22 Apr 2024 14:34:54 -0300 Subject: [PATCH 24/30] refactor AddressChoice --- .../popup/pages/hw/gridplus/addressChoice.tsx | 190 +++++------------- .../pages/hw/gridplus/walletCredentials.tsx | 5 + 2 files changed, 51 insertions(+), 144 deletions(-) diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index d6babfc51c..b77b32d8fb 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -1,160 +1,62 @@ -import { getAddress } from '@ethersproject/address'; -import { motion } from 'framer-motion'; -import { fetchAddresses } from 'gridplus-sdk'; -import { FormEvent, useEffect, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { Address } from 'wagmi'; +import { useQuery } from '@tanstack/react-query'; +import gridplus from 'gridplus-sdk'; +import { Navigate, useLocation } from 'react-router-dom'; -import { i18n } from '~/core/languages'; -import { SessionStorage } from '~/core/storage'; -import { truncateAddress } from '~/core/utils/address'; -import { Box, Button, Text } from '~/design-system'; -import { Checkbox } from '~/entries/popup/components/Checkbox/Checkbox'; -import { Link } from '~/entries/popup/components/Link/Link'; +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 { useAccounts } from '~/entries/popup/hooks/useAccounts'; import { ROUTES } from '~/entries/popup/urls'; -export type AddressesData = { - addresses: Address[]; -}; +const useGridPlusAddresses = () => + useQuery({ + queryKey: ['gridplusAddressess', useGridPlusClientStore.getState().client], + queryFn: async () => { + if (process.env.IS_TESTING === 'true') { + return HARDWARE_WALLETS.MOCK_ACCOUNT.accountsToImport.map( + (account) => account.address, + ); + } + + 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), + ); + }, + }); export const AddressChoice = () => { - const navigate = useNavigate(); const { state } = useLocation(); - const { sortedAccounts } = useAccounts(); - const [formData, setFormData] = useState({ - selectedAddresses: [] as string[], - }); - const [loadingAddresses, setLoadingAddresses] = useState(true); - const [addresses, setAddresses] = useState([]); - const disabled = loadingAddresses || formData.selectedAddresses.length === 0; - const toggleAddress = (address: string) => { - const selected = formData.selectedAddresses.includes(address); - if (selected) - return setFormData({ - selectedAddresses: formData.selectedAddresses.filter( - (currentAddress) => currentAddress !== address, - ), - }); - return setFormData({ - selectedAddresses: [...formData.selectedAddresses, address], - }); - }; - const onSubmit = (event: FormEvent) => { - event.preventDefault(); - const accountsToImport = formData.selectedAddresses.map((address, i) => ({ - address, - index: i, - })); - navigate(ROUTES.HW_WALLET_LIST, { - state: { + + const { data: addresses } = useGridPlusAddresses(); + + if (!addresses || addresses.length === 0) return ; + + const accountsToImport = addresses.map((address, i) => ({ + address, + index: i, + })); + + return ( + { - const fetchWalletAddresses = async () => { - setLoadingAddresses(true); - let fetchedAddresses: Address[]; - if (process.env.IS_TESTING === 'true') { - fetchedAddresses = HARDWARE_WALLETS.MOCK_ACCOUNT.accountsToImport.map( - (account) => account.address, - ); - } else { - fetchedAddresses = (await fetchAddresses()) as Address[]; - } - const nonExistingAddresses = fetchedAddresses - .map((address) => getAddress(address)) - .filter( - (address) => - !sortedAccounts.map((account) => account.address).includes(address), - ); - setAddresses(nonExistingAddresses); - setLoadingAddresses(false); - }; - const setPersistedFormData = async () => { - const persistedAddresses = JSON.parse( - (await SessionStorage.get('gridplusPersistedAddresses')) ?? '[]', - ) as string[]; - if (persistedAddresses.length < 1) return; - setFormData({ selectedAddresses: persistedAddresses }); - }; - fetchWalletAddresses(); - setPersistedFormData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - useEffect(() => { - SessionStorage.set( - 'gridplusPersistedAddresses', - JSON.stringify(formData.selectedAddresses), - ); - }, [formData.selectedAddresses]); - return ( - - - - {i18n.t('hw.gridplus_choose_addresses')} - - {loadingAddresses && } - - - {i18n.t('hw.available_addresses')} - - {addresses.map((address, i) => ( - - toggleAddress(address)} - selected={formData.selectedAddresses.includes(address)} - testId={`gridplus-address-${i}`} - /> - - #{i}: - - toggleAddress(address)} color=""> - - {truncateAddress(address)} - - - - ))} - - - - + }} + /> ); }; diff --git a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx index 8b2e646be5..abced154eb 100644 --- a/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx +++ b/src/entries/popup/pages/hw/gridplus/walletCredentials.tsx @@ -30,12 +30,15 @@ export const WalletCredentials = ({ }); const formDataFilled = formData.deviceId.length > 0 && formData.password.length > 0; + const disabled = !formDataFilled || connecting; + const setStoredClient = (storedClient: string | null) => { if (!storedClient) return; setStoredGridPlusClient(storedClient); setClient(storedClient); }; + const onSubmit = async (event: FormEvent) => { event.preventDefault(); setConnecting(true); @@ -58,6 +61,7 @@ export const WalletCredentials = ({ setConnecting(false); } }; + useEffect(() => { const checkPersistedClient = async () => { const gridPlusClient = await getStoredGridPlusClient(); @@ -72,6 +76,7 @@ export const WalletCredentials = ({ }; checkPersistedClient(); }, [appName, onAfterSetup]); + return ( Date: Mon, 22 Apr 2024 19:55:21 -0300 Subject: [PATCH 25/30] a --- .../popup/pages/hw/gridplus/addressChoice.tsx | 20 +++-- .../popup/pages/hw/walletList/index.tsx | 85 +++++++++++-------- 2 files changed, 63 insertions(+), 42 deletions(-) diff --git a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx index b77b32d8fb..74c999bd1b 100644 --- a/src/entries/popup/pages/hw/gridplus/addressChoice.tsx +++ b/src/entries/popup/pages/hw/gridplus/addressChoice.tsx @@ -11,7 +11,7 @@ import { ROUTES } from '~/entries/popup/urls'; const useGridPlusAddresses = () => useQuery({ - queryKey: ['gridplusAddressess', useGridPlusClientStore.getState().client], + queryKey: ['gridplusAddresses', useGridPlusClientStore.getState().client], queryFn: async () => { if (process.env.IS_TESTING === 'true') { return HARDWARE_WALLETS.MOCK_ACCOUNT.accountsToImport.map( @@ -19,6 +19,12 @@ const useGridPlusAddresses = () => ); } + /* + 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(); @@ -32,18 +38,21 @@ const useGridPlusAddresses = () => (address) => !alreadyAddedOwnedAccounts.includes(address), ); }, + staleTime: 0, + cacheTime: 0, }); export const AddressChoice = () => { const { state } = useLocation(); - const { data: addresses } = useGridPlusAddresses(); + const { data: addresses, isFetching } = useGridPlusAddresses(); - if (!addresses || addresses.length === 0) return ; + if (isFetching || !addresses || addresses.length === 0) + return ; - const accountsToImport = addresses.map((address, i) => ({ + const accountsToImport = addresses.map((address) => ({ address, - index: i, + index: -1, // GridPlus doesn't support add by index, gonna keep it with a negative value to avoid refactoring the whole flow })); return ( @@ -56,6 +65,7 @@ export const AddressChoice = () => { vendor: 'GridPlus', direction: state?.direction, navbarIcon: state?.navbarIcon, + supportsAddByIndex: false, }} /> ); diff --git a/src/entries/popup/pages/hw/walletList/index.tsx b/src/entries/popup/pages/hw/walletList/index.tsx index 048b0b3f16..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'; @@ -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 && ( {isTakingTooLong && ( - This is taking longer than expected check if your device is connected - to the internet + {i18n.t('hw.connect_gridplus_taking_too_long')} )}
diff --git a/static/json/languages/en_US.json b/static/json/languages/en_US.json index 45d4289de7..26c170128b 100644 --- a/static/json/languages/en_US.json +++ b/static/json/languages/en_US.json @@ -1662,6 +1662,7 @@ "needs_app_ledger_description_2": "Enter your passcode to unlock your Ledger. Once unlocked, open the Ethereum app by pressing both buttons at once.", "connect_trezor_title": "Complete your Trezor set up", "connect_gridplus_title": "Connect your Lattice1", + "connect_gridplus_taking_too_long": "This is taking longer than expected check if your device is connected to the internet", "connect_trezor_description": "Continue to connect your Trezor to Rainbow through the web interface.", "connect_gridplus_description": "Connect your Lattice1 to Rainbow using the Connection Wizard.", "learn_more": "Learn more.",