diff --git a/package.json b/package.json index 28df48c28..17bf7eaff 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "react": "^16.12.0", "react-copy-to-clipboard": "^5.0.2", "react-dom": "^16.12.0", - "react-hook-form": "^5.2.0", + "react-hook-form": "^5.7.2", "react-hot-loader": "^4.12.19", "react-router-dom": "^5.1.2", "react-select": "^3.0.8", @@ -70,6 +70,7 @@ "@babel/preset-react": "^7.9.4", "@babel/register": "^7.8.3", "@fortawesome/fontawesome-common-types": "^0.2.26", + "@hookform/devtools": "^1.2.1", "@types/bn.js": "^4.11.6", "@types/combine-reducers": "^1.0.0", "@types/enzyme": "^3.10.4", @@ -117,7 +118,6 @@ "preload-webpack-plugin": "^3.0.0-beta.4", "prettier": "^1.19.1", "react-dev-utils": "^10.0.0", - "react-hook-form-devtools": "^1.1.3", "react-test-renderer": "^16.12.0", "rimraf": "^3.0.1", "style-loader": "^1.1.3", diff --git a/src/App.tsx b/src/App.tsx index ba965bc05..2636d2cb9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -78,6 +78,12 @@ const OrderBook = React.lazy(() => 'pages/OrderBook' ), ) +const Settings = React.lazy(() => + import( + /* webpackChunkName: "Settings_chunk"*/ + 'pages/Settings' + ), +) // Global State import { withGlobalContext } from 'hooks/useGlobalState' @@ -104,6 +110,7 @@ const App: React.FC = () => ( + diff --git a/src/HookFormDevtool.tsx b/src/HookFormDevtool.tsx new file mode 100644 index 000000000..ab7f2ebd2 --- /dev/null +++ b/src/HookFormDevtool.tsx @@ -0,0 +1 @@ +export { DevTool } from '@hookform/devtools' diff --git a/src/api/wallet/WalletApi.ts b/src/api/wallet/WalletApi.ts index ea0d43ffc..69d57b669 100644 --- a/src/api/wallet/WalletApi.ts +++ b/src/api/wallet/WalletApi.ts @@ -8,8 +8,7 @@ import Web3Modal, { getProviderInfo, IProviderOptions, IProviderInfo, isMobile } import Web3 from 'web3' import { BlockHeader } from 'web3-eth' -import { logDebug, toBN, txDataEncoder } from 'utils' -import { INFURA_ID, WALLET_CONNECT_BRIDGE } from 'const' +import { logDebug, toBN, txDataEncoder, generateWCOptions } from 'utils' import { subscribeToWeb3Event } from './subscriptionHelpers' import { getMatchingScreenSize, subscribeToScreenSizeChange } from 'utils/mediaQueries' @@ -44,6 +43,7 @@ export interface WalletApi { isConnected(): Promise connect(givenProvider?: Provider): Promise disconnect(): Promise + reconnectWC(): Promise getAddress(): Promise getBalance(): Promise getNetworkId(): Promise @@ -221,13 +221,6 @@ const subscribeToBlockchainUpdate = async ({ type WalletConnectInits = IProviderOptions['walletconnect'] -const wcOptions: Omit = { - options: { - infuraId: INFURA_ID, - bridge: WALLET_CONNECT_BRIDGE, - }, -} - // needed if Web3 was pre-instantiated with wss | WebsocketProvider const closeOpenWebSocketConnection = (web3: Web3): void => { if ( @@ -274,14 +267,28 @@ export class WalletApiImpl implements WalletApi { return this._connected } + public async reconnectWC(): Promise { + // if connected to WC reconnect with new data + if (await this.isConnected()) { + if (isWalletConnectProvider(this._provider)) { + await this.disconnect() + return this.connect() + } + } + + // if not don't do anything + return false + } + public async connect(givenProvider?: Provider): Promise { let provider: Provider if (givenProvider) { provider = givenProvider } else { + const options = generateWCOptions() const WCoptions: WalletConnectInits = { - ...wcOptions, + options, package: ( await import( /* webpackChunkName: "@walletconnect"*/ diff --git a/src/api/wallet/WalletApiMock.ts b/src/api/wallet/WalletApiMock.ts index ddcb109be..e2451b4ba 100644 --- a/src/api/wallet/WalletApiMock.ts +++ b/src/api/wallet/WalletApiMock.ts @@ -53,6 +53,11 @@ export class WalletApiMock implements WalletApi { await this._notifyListeners() } + public async reconnectWC(): Promise { + await this.disconnect() + return this.connect() + } + public async getAddress(): Promise { assert(this._connected, 'The wallet is not connected') diff --git a/src/components/Settings/WalletConnect.tsx b/src/components/Settings/WalletConnect.tsx new file mode 100644 index 000000000..d37475f8b --- /dev/null +++ b/src/components/Settings/WalletConnect.tsx @@ -0,0 +1,236 @@ +import React from 'react' +import { FormContextValues, ErrorMessage, FieldErrors } from 'react-hook-form' +import styled from 'styled-components' +import { MEDIA } from 'const' + +import Joi from '@hapi/joi' +import { Resolver, SettingsFormData } from 'pages/Settings' +import { WCOptions } from 'utils' + +const URLSchema = Joi.string() + .empty('') + .optional() + .uri({ scheme: ['http', 'https'] }) +const BridgeSchema = URLSchema.message('Bridge must be a valid URL') +const RPCSchema = URLSchema.message('RPC must be a valid URL') +const InfuraIdSchema = Joi.string() + .empty('') + .optional() + .length(32) + .message('Must be a valid id') + +const WCSettingsSchema = Joi.object({ + bridge: BridgeSchema, + infuraId: InfuraIdSchema, + rpc: Joi.object({ + mainnet: RPCSchema, + rinkeby: RPCSchema, + }).empty({ + mainnet: '', + rinkeby: '', + }), +}) + .oxor('infuraId', 'rpc') + .messages({ 'object.oxor': 'InfuraId and RPC are mutually exclusive' }) +// bridge is optional +// infuraId and rpc are optional and exclusive + +// validates only walletconnect slice of form data +export const wcResolver: Resolver = ( + data: WCOptions, +): { + values: WCOptions | null + errors: FieldErrors | null + name: 'walletconnect' +} => { + const result = WCSettingsSchema.validate(data, { + abortEarly: false, + }) + + const { value: values, error } = result + + return { + name: 'walletconnect', + values: error ? null : values, + errors: error + ? error.details.reduce((previous, currentError) => { + // when exlusive fields are both present + if (currentError.path.length === 0 && currentError.type === 'object.oxor') { + return { + ...previous, + infuraId: currentError, + rpc: currentError, + } + } + // any other error + return { + ...previous, + [currentError.path[0]]: currentError, + } + }, {}) + : null, + } +} + +interface WCSettingsProps { + register: FormContextValues['register'] + errors: FieldErrors +} + +const OuterFormSection = styled.div` + display: flex; + flex-direction: column; + font-size: 1.5em; + + > div { + margin: 0.5em 0; + } +` + +const AlternativesSection = styled.div` + display: grid; + grid-template-columns: 1fr auto 1fr; + + @media ${MEDIA.mobile} { + grid-template-rows: 1fr auto 1fr; + grid-template-columns: 100%; + } +` + +const OrSeparator = styled.div` + padding: 1.5em; + font-size: 0.8em; + display: flex; + justify-content: center; + align-items: center; +} +` + +const ErrorWrapper = styled.p` + color: var(--color-error); + margin: 0; + padding: 0.5em; + font-size: 0.7em; +` + +const InnerFormSection = styled.div` + padding: 0.5em; + padding-bottom: 1em; + box-shadow: 0 0 7px 0px grey; + border-radius: 0.5rem; + background: var(--color-background); + position: relative; + + ${ErrorWrapper} { + position: absolute; + bottom: 0; + } +` + +const FormField = styled.label` + display: flex; + flex-direction: column; + + > * { + padding-left: 0.65rem; + } + + > span { + font-weight: bold; + } + + > input { + width: auto; + font-size: 1em; + font-weight: normal; + + ::placeholder { + opacity: 0.2; + } + } +` + +const Disclaimer = styled.div` + display: flex; + justify-content: center; + + > p { + font-size: 1.4em; + max-width: 600px; + width: 100%; + padding: 0.5em; + border-radius: 0.8rem; + } +` + +export const WCSettings: React.FC = ({ register, errors }) => { + return ( + + + + Here you can set InfuraId or RPC URL that will be used for connecting + WalletConnect provider to mainnet and rinkeby. It is also possible to set WalletConnect{' '} + Bridge URL to use instead of the default one. + + + + + + + InfuraId + + + + + + OR + + + + RPC URL + + + + + + + + + + Bridge URL + + + + + + + ) +} + +interface WCErrorsProps { + errors: FieldErrors + name: string +} + +const WCError: React.FC = ({ errors, name }) => { + return ( + + + + ) +} diff --git a/src/components/TradeWidget/index.tsx b/src/components/TradeWidget/index.tsx index 22607909a..f64987e05 100644 --- a/src/components/TradeWidget/index.tsx +++ b/src/components/TradeWidget/index.tsx @@ -63,6 +63,8 @@ import validationSchema from './validationSchema' import { useBetterAddTokenModal } from 'hooks/useBetterAddTokenModal' import { encodeTokenSymbol, decodeSymbol } from '@gnosis.pm/dex-js' +import { DevTool } from 'HookFormDevtool' + const WrappedWidget = styled(Widget)` overflow-x: visible; min-width: 0; @@ -1008,8 +1010,7 @@ const TradeWidget: React.FC = () => { {/* React Forms DevTool debugger */} - {process.env.NODE_ENV === 'development' && - React.createElement(require('react-hook-form-devtools').DevTool, { control: methods.control })} + {process.env.NODE_ENV === 'development' && } ) } diff --git a/src/const.ts b/src/const.ts index 81b02f9e7..b122d736f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -119,6 +119,7 @@ if (process.env.INFURA_ID) { export const INFURA_ID = infuraId export const WALLET_CONNECT_BRIDGE = process.env.WALLET_CONNECT_BRIDGE || CONFIG.walletConnect.bridge +export const STORAGE_KEY_CUSTOM_WC_OPTIONS = 'CustomWCOptions' let ethNodeUrl if (process.env.ETH_NODE_URL) { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 000000000..547961b88 --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,175 @@ +import React, { useMemo } from 'react' +import { useForm, ValidationResolver, FieldErrors } from 'react-hook-form' +import { DevTool } from 'HookFormDevtool' +import styled from 'styled-components' + +import { walletApi } from 'api' +import { setCustomWCOptions, getWCOptionsFromStorage, WCOptions } from 'utils' +import { useHistory } from 'react-router' +import { WCSettings, wcResolver } from 'components/Settings/WalletConnect' + +const SettingsButtonSubmit = styled.button` + height: 3.6rem; + letter-spacing: 0.03rem; + + border-radius: 0.6rem; + padding: 0 1.6rem; + text-transform: uppercase; + font-size: 1.4rem; + line-height: 1; +` + +const ButtonsContainer = styled.div` + margin-top: 2em; + display: flex; + justify-content: space-between; +` + +const SettingsButtonReset = styled(SettingsButtonSubmit)` + background-color: transparent; + color: var(--color-text-active); + + &:hover { + color: var(--color-background-button-hover); + background-color: transparent; + } +` + +export interface SettingsFormData { + walletconnect: WCOptions +} + +export interface ResolverResult { + values: T[K] | null + errors: FieldErrors | null + name: K +} + +export interface Resolver { + (data: T[K]): ResolverResult +} + +const composeValuesErrors = ( + resolvedResults: { errors: null | FieldErrors; values: null | T[K]; name: K }[], +): { + values: T | null + errors: FieldErrors | null +} => { + const { errors, values } = resolvedResults.reduce<{ + errors: null | FieldErrors + values: null | T + }>( + (acc, elem) => { + // accumulate errors + // or leave as null + if (elem.errors) { + if (!acc.errors) acc.errors = {} + + acc.errors = { + ...acc.errors, + [elem.name]: elem.errors, + } + } + + // accumulate values + // or set to null if there are errors + if (acc.errors) { + acc.values = null + return acc + } + + if (!elem.values) return acc + + if (!acc.values) acc.values = {} as T + acc.values = { + ...acc.values, + [elem.name]: elem.values, + } + + return acc + }, + { errors: null, values: null }, + ) + + return { + values: errors ? null : values, + errors, + } +} + +const composeResolvers = (resolvers: { [K in keyof SettingsFormData]: Resolver }) => { + return (data: SettingsFormData): ResolverResult[] => { + return Object.keys(data).map((key: keyof SettingsFormData) => { + const resolver = resolvers[key] + return resolver(data[key]) + }) + } +} + +const mainResolver = composeResolvers({ + walletconnect: wcResolver, +}) + +const resolver: ValidationResolver = data => { + const results = mainResolver(data) + + // potentially allow for Setting sections other than WalletConnect + const { values, errors } = composeValuesErrors(results) + + return { + values: errors ? {} : values || {}, + errors: errors || {}, + } +} + +const SettingsWrapper = styled.div` + width: 100%; + background: var(--color-background-pageWrapper); + padding: 2em; + border-radius: 1rem; +` + +const getDefaultSettings = (): SettingsFormData => ({ + walletconnect: getWCOptionsFromStorage(), +}) + +const Settings: React.FC = () => { + // to not touch localStorage on every render + // but at the same time pull in updated values from storage on mount + const defaultValues = useMemo(getDefaultSettings, []) + + const { register, handleSubmit, errors, control } = useForm({ + validationResolver: resolver, + defaultValues, + }) + + const history = useHistory() + + const onSubmit = async (data: SettingsFormData): Promise => { + if (data.walletconnect) { + // if options didn't change, exit early + if (!setCustomWCOptions(data.walletconnect)) return + + // connect with new options + // with Web3Modal prompt and everything + const reconnected = await walletApi.reconnectWC() + // if successful, redirect to home + if (reconnected) history.push('/') + } + } + + return ( + + + + + Reset + Apply Settings + + + {process.env.NODE_ENV === 'development' && } + + ) +} + +export default Settings diff --git a/src/utils/autoconnect.ts b/src/utils/autoconnect.ts index bb993e406..57ed672e5 100644 --- a/src/utils/autoconnect.ts +++ b/src/utils/autoconnect.ts @@ -1,5 +1,5 @@ -import { delay } from 'utils' -import { INFURA_ID, STORAGE_KEY_LAST_PROVIDER, WALLET_CONNECT_BRIDGE } from 'const' +import { delay, generateWCOptions } from 'utils' +import { STORAGE_KEY_LAST_PROVIDER } from 'const' import { WalletApi } from 'api/wallet/WalletApi' import { logDebug } from 'utils' @@ -8,22 +8,13 @@ const getWCIfConnected = async (): Promise => { /* webpackChunkName: "@walletconnect"*/ '@walletconnect/web3-provider' ) - const provider = new WalletConnectProvider({ - infuraId: INFURA_ID, - bridge: WALLET_CONNECT_BRIDGE, - }) + const wcOptions = generateWCOptions() + + const provider = new WalletConnectProvider(wcOptions) if (!provider.wc.connected) return null try { - await new Promise((resolve, reject) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - provider.wc.on('transport_open', (error: Error, event: any) => { - if (error) reject(error) - else resolve(event) - }) - }) - await Promise.race([ // some time for connection to settle delay(250), diff --git a/src/utils/index.ts b/src/utils/index.ts index 14b003a1f..04ad469bd 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,3 +8,4 @@ export * from './validation' export * from './flagCodes' export * from './deferred' export * from './classifiers' +export * from './walletconnectOptions' diff --git a/src/utils/walletconnectOptions.ts b/src/utils/walletconnectOptions.ts new file mode 100644 index 000000000..3bb0572be --- /dev/null +++ b/src/utils/walletconnectOptions.ts @@ -0,0 +1,51 @@ +import { INFURA_ID, WALLET_CONNECT_BRIDGE, STORAGE_KEY_CUSTOM_WC_OPTIONS } from 'const' +import { IWalletConnectProviderOptions, IRPCMap } from '@walletconnect/types' +import { Network } from 'types' + +export interface WCOptions { + infuraId?: string + bridge?: string + rpc?: { + mainnet?: string + rinkeby?: string + } +} + +export const setCustomWCOptions = (options: WCOptions): boolean => { + const optionsStr = JSON.stringify(options) + const oldStr = localStorage.getItem(STORAGE_KEY_CUSTOM_WC_OPTIONS) + + // no change,no need to reconnect + if (optionsStr === oldStr) return false + + localStorage.setItem(STORAGE_KEY_CUSTOM_WC_OPTIONS, optionsStr) + return true +} + +export const getWCOptionsFromStorage = (): WCOptions => { + const storedOptions = localStorage.getItem(STORAGE_KEY_CUSTOM_WC_OPTIONS) + if (!storedOptions) return {} + + return JSON.parse(storedOptions) +} + +const mapStoredRpc = (rpc?: WCOptions['rpc']): IRPCMap | undefined => { + if (!rpc) return + + const { mainnet, rinkeby } = rpc + + const rpcMap = {} + if (mainnet) rpcMap[Network.Mainnet] = mainnet + if (rinkeby) rpcMap[Network.Rinkeby] = rinkeby + + return rpcMap +} + +export const generateWCOptions = (): IWalletConnectProviderOptions => { + const { infuraId, bridge, rpc } = getWCOptionsFromStorage() + return { + infuraId: infuraId || INFURA_ID, + bridge: bridge || WALLET_CONNECT_BRIDGE, + rpc: mapStoredRpc(rpc), + } +} diff --git a/yarn.lock b/yarn.lock index fa0b819b1..ad3dbdc63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1285,6 +1285,18 @@ dependencies: "@hapi/hoek" "^9.0.0" +"@hookform/devtools@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@hookform/devtools/-/devtools-1.2.1.tgz#505bc95ae60c3d9f625c17f1f80ac68007eea5c5" + integrity sha512-hpf7hawIeb/36Ti0C/3lXBxCCO+Xsj51JV8Sj+0dVBqG0Q/gyayhWkqCJBT4FqoIYIEd7uyeL7HDDQqQ7uGsVg== + dependencies: + "@emotion/core" "^10.0.27" + "@emotion/styled" "^10.0.27" + "@types/lodash" "^4.14.149" + little-state-machine "^3.0.0" + lodash "^4.17.15" + react-simple-animate "^3.3.6" + "@hot-loader/react-dom@^16.11.0": version "16.13.0" resolved "https://registry.yarnpkg.com/@hot-loader/react-dom/-/react-dom-16.13.0.tgz#de245b42358110baf80aaf47a0592153d4047997" @@ -11991,19 +12003,7 @@ react-error-overlay@^6.0.7: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108" integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA== -react-hook-form-devtools@^1.1.3: - version "1.2.0" - resolved "https://registry.yarnpkg.com/react-hook-form-devtools/-/react-hook-form-devtools-1.2.0.tgz#d675870426ab126575f59009265b2232ac6c1b9c" - integrity sha512-Pd2qqLrzY/y4Jpodvov74YSa9SWbl3hVE8xLfIUt+jkP0ay98m31hn8B1Z78abGm9Z+vEHRwmuFlt3Nv05QYCw== - dependencies: - "@emotion/core" "^10.0.27" - "@emotion/styled" "^10.0.27" - "@types/lodash" "^4.14.149" - little-state-machine "^3.0.0" - lodash "^4.17.15" - react-simple-animate "^3.3.6" - -react-hook-form@^5.2.0: +react-hook-form@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-5.7.2.tgz#a84e259e5d37dd30949af4f79c4dac31101b79ac" integrity sha512-bJvY348vayIvEUmSK7Fvea/NgqbT2racA2IbnJz/aPlQ3GBtaTeDITH6rtCa6y++obZzG6E3Q8VuoXPir7QYUg==
+ Here you can set InfuraId or RPC URL that will be used for connecting + WalletConnect provider to mainnet and rinkeby. It is also possible to set WalletConnect{' '} + Bridge URL to use instead of the default one. +