From ce96873a720d27e6152b54b54ce943c19a8624a6 Mon Sep 17 00:00:00 2001 From: Jordan Frankfurt Date: Wed, 26 Jan 2022 13:14:18 -0500 Subject: [PATCH] feat(widgets): use default input/output (#3161) * feat: use default input/output on chain switch * feat(widgets): ErrorGenerator -> PropValidator * default prop validation * useDefaults hook * pr feedback * fix cosmos * drop token map changes * add default inputs to cosmos fixture * set up different validation layers for widget and swap * split widget/swap prop types * cleanup * pr feedback * clear defaults when they're no longer valid on the current chain * remove state checks on validators * stop using address in cosmos fixture * pr feedback * useMemo on useSwapDefaults args * tell the user what they gave to error'd props Co-authored-by: Zach Pomerantz --- src/lib/components/Error/ErrorGenerator.tsx | 48 ------------ .../Error/WidgetsPropsValidator.tsx | 22 ++++++ src/lib/components/Swap/Swap.fixture.tsx | 38 +++++++++- src/lib/components/Swap/SwapPropValidator.tsx | 76 +++++++++++++++++++ src/lib/components/Swap/index.tsx | 67 +++++----------- src/lib/components/Widget.tsx | 31 ++++---- src/lib/hooks/swap/useSwapDefaults.ts | 70 +++++++++++++++++ src/lib/index.tsx | 6 +- src/lib/state/swap.ts | 6 +- 9 files changed, 242 insertions(+), 122 deletions(-) delete mode 100644 src/lib/components/Error/ErrorGenerator.tsx create mode 100644 src/lib/components/Error/WidgetsPropsValidator.tsx create mode 100644 src/lib/components/Swap/SwapPropValidator.tsx create mode 100644 src/lib/hooks/swap/useSwapDefaults.ts diff --git a/src/lib/components/Error/ErrorGenerator.tsx b/src/lib/components/Error/ErrorGenerator.tsx deleted file mode 100644 index ff079b11f8..0000000000 --- a/src/lib/components/Error/ErrorGenerator.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { IntegrationError } from 'lib/errors' -import { SwapWidgetProps } from 'lib/index' -import { useEffect } from 'react' - -import { isAddress } from '../../../utils' - -export default function ErrorGenerator(swapWidgetProps: SwapWidgetProps) { - const { jsonRpcEndpoint, provider } = swapWidgetProps - useEffect(() => { - if (!provider && !jsonRpcEndpoint) { - throw new IntegrationError('This widget requires a provider or jsonRpcEndpoint.') - } - }, [provider, jsonRpcEndpoint]) - - // size constraints - const { width } = swapWidgetProps - useEffect(() => { - if (width && width < 300) { - throw new IntegrationError('Set widget width to at least 300px.') - } - }, [width]) - - // convenience fee constraints - const { convenienceFee, convenienceFeeRecipient } = swapWidgetProps - useEffect(() => { - if (convenienceFee) { - if (convenienceFee > 100 || convenienceFee < 0) { - throw new IntegrationError('Set widget convenienceFee to at least 400px.') - } - if (!convenienceFeeRecipient) { - throw new IntegrationError('convenienceFeeRecipient is required when convenienceFee is set.') - } - const MustBeValidAddressError = new IntegrationError('convenienceFeeRecipient must be a valid address.') - if (typeof convenienceFeeRecipient === 'string') { - if (!isAddress(convenienceFeeRecipient)) { - throw MustBeValidAddressError - } - } else if (typeof convenienceFeeRecipient === 'object') { - Object.values(convenienceFeeRecipient).forEach((recipient) => { - if (!isAddress(recipient)) { - throw MustBeValidAddressError - } - }) - } - } - }, [convenienceFee, convenienceFeeRecipient]) - return null -} diff --git a/src/lib/components/Error/WidgetsPropsValidator.tsx b/src/lib/components/Error/WidgetsPropsValidator.tsx new file mode 100644 index 0000000000..0c1bef1402 --- /dev/null +++ b/src/lib/components/Error/WidgetsPropsValidator.tsx @@ -0,0 +1,22 @@ +import { WidgetProps } from 'lib/components/Widget' +import { IntegrationError } from 'lib/errors' +import { PropsWithChildren, useEffect } from 'react' + +export default function WidgetsPropsValidator(props: PropsWithChildren) { + const { jsonRpcEndpoint, provider } = props + + useEffect(() => { + if (!provider && !jsonRpcEndpoint) { + throw new IntegrationError('This widget requires a provider or jsonRpcEndpoint.') + } + }, [provider, jsonRpcEndpoint]) + + const { width } = props + useEffect(() => { + if (width && width < 300) { + throw new IntegrationError(`Set widget width to at least 300px. (You set it to ${width}.)`) + } + }, [width]) + + return <>{props.children} +} diff --git a/src/lib/components/Swap/Swap.fixture.tsx b/src/lib/components/Swap/Swap.fixture.tsx index 5cc8d024b2..8f1684281f 100644 --- a/src/lib/components/Swap/Swap.fixture.tsx +++ b/src/lib/components/Swap/Swap.fixture.tsx @@ -1,7 +1,8 @@ import { tokens } from '@uniswap/default-token-list' +import { DAI, USDC } from 'constants/tokens' import { useUpdateAtom } from 'jotai/utils' import { useEffect } from 'react' -import { useValue } from 'react-cosmos/fixture' +import { useSelect, useValue } from 'react-cosmos/fixture' import Swap from '.' import { colorAtom } from './Output' @@ -24,7 +25,40 @@ function Fixture() { } }, [color, setColor]) - return + const optionsToAddressMap: Record = { + none: '', + Native: 'NATIVE', + DAI: DAI.address, + USDC: USDC.address, + } + const addressOptions = Object.keys(optionsToAddressMap) + const [defaultInput] = useSelect('defaultInputAddress', { + options: addressOptions, + defaultValue: addressOptions[2], + }) + const inputOptions = ['', '0', '100', '-1'] + const [defaultInputAmount] = useSelect('defaultInputAmount', { + options: inputOptions, + defaultValue: inputOptions[2], + }) + const [defaultOutput] = useSelect('defaultOutputAddress', { + options: addressOptions, + defaultValue: addressOptions[1], + }) + const [defaultOutputAmount] = useSelect('defaultOutputAmount', { + options: inputOptions, + defaultValue: inputOptions[0], + }) + + return ( + + ) } export default diff --git a/src/lib/components/Swap/SwapPropValidator.tsx b/src/lib/components/Swap/SwapPropValidator.tsx new file mode 100644 index 0000000000..20ccc73496 --- /dev/null +++ b/src/lib/components/Swap/SwapPropValidator.tsx @@ -0,0 +1,76 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { DefaultAddress, SwapProps } from 'lib/components/Swap' +import { IntegrationError } from 'lib/errors' +import { PropsWithChildren, useEffect } from 'react' + +import { isAddress } from '../../../utils' + +function isAddressOrAddressMap(addressOrMap: DefaultAddress): boolean { + if (typeof addressOrMap === 'object') { + return Object.values(addressOrMap).every((address) => isAddress(address)) + } + if (typeof addressOrMap === 'string') { + return typeof isAddress(addressOrMap) === 'string' + } + return false +} + +type ValidatorProps = PropsWithChildren + +export default function SwapPropValidator(props: ValidatorProps) { + const { convenienceFee, convenienceFeeRecipient } = props + useEffect(() => { + if (convenienceFee) { + if (convenienceFee > 100 || convenienceFee < 0) { + throw new IntegrationError(`convenienceFee must be between 0 and 100. (You set it to ${convenienceFee})`) + } + if (!convenienceFeeRecipient) { + throw new IntegrationError('convenienceFeeRecipient is required when convenienceFee is set.') + } + + if (typeof convenienceFeeRecipient === 'string') { + if (!isAddress(convenienceFeeRecipient)) { + throw new IntegrationError( + `convenienceFeeRecipient must be a valid address. (You set it to ${convenienceFeeRecipient}.)` + ) + } + } else if (typeof convenienceFeeRecipient === 'object') { + Object.values(convenienceFeeRecipient).forEach((recipient) => { + if (!isAddress(recipient)) { + const values = Object.values(convenienceFeeRecipient).join(', ') + throw new IntegrationError( + `All values in convenienceFeeRecipient object must be valid addresses. (You used ${values}.)` + ) + } + }) + } + } + }, [convenienceFee, convenienceFeeRecipient]) + + const { defaultInputAddress, defaultInputAmount, defaultOutputAddress, defaultOutputAmount } = props + useEffect(() => { + if (defaultOutputAmount && defaultInputAmount) { + throw new IntegrationError('defaultInputAmount and defaultOutputAmount may not both be defined.') + } + if (defaultInputAmount && BigNumber.from(defaultInputAmount).lt(0)) { + throw new IntegrationError(`defaultInputAmount must be a positive number. (You set it to ${defaultInputAmount})`) + } + if (defaultOutputAmount && BigNumber.from(defaultOutputAmount).lt(0)) { + throw new IntegrationError( + `defaultOutputAmount must be a positive number. (You set it to ${defaultOutputAmount})` + ) + } + if (defaultInputAddress && !isAddressOrAddressMap(defaultInputAddress) && defaultInputAddress !== 'NATIVE') { + throw new IntegrationError( + `defaultInputAddress(es) must be a valid address or "NATIVE". (You set it to ${defaultInputAddress}` + ) + } + if (defaultOutputAddress && !isAddressOrAddressMap(defaultOutputAddress) && defaultOutputAddress !== 'NATIVE') { + throw new IntegrationError( + `defaultOutputAddress(es) must be a valid address or "NATIVE". (You set it to ${defaultOutputAddress}` + ) + } + }, [defaultInputAddress, defaultInputAmount, defaultOutputAddress, defaultOutputAmount]) + + return <>{props.children} +} diff --git a/src/lib/components/Swap/index.tsx b/src/lib/components/Swap/index.tsx index 75da946554..109b156de7 100644 --- a/src/lib/components/Swap/index.tsx +++ b/src/lib/components/Swap/index.tsx @@ -1,12 +1,10 @@ import { Trans } from '@lingui/macro' import { TokenInfo } from '@uniswap/token-lists' -import { nativeOnChain } from 'constants/tokens' -import { useSwapAmount, useSwapCurrency } from 'lib/hooks/swap' +import useSwapDefaults from 'lib/hooks/swap/useSwapDefaults' import { SwapInfoUpdater } from 'lib/hooks/swap/useSwapInfo' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' -import useTokenList, { DEFAULT_TOKEN_LIST } from 'lib/hooks/useTokenList' -import { Field } from 'lib/state/swap' -import { useLayoutEffect, useMemo, useState } from 'react' +import useTokenList from 'lib/hooks/useTokenList' +import { useState } from 'react' import Header from '../Header' import { BoundaryProvider } from '../Popover' @@ -16,60 +14,29 @@ import Output from './Output' import ReverseButton from './ReverseButton' import Settings from './Settings' import SwapButton from './SwapButton' +import SwapPropValidator from './SwapPropValidator' import Toolbar from './Toolbar' -interface DefaultTokenAmount { - address?: string | { [chainId: number]: string } - amount?: number -} - -interface SwapDefaults { - tokenList: string | TokenInfo[] - input: DefaultTokenAmount - output: DefaultTokenAmount -} - -const DEFAULT_INPUT: DefaultTokenAmount = { address: 'ETH' } -const DEFAULT_OUTPUT: DefaultTokenAmount = {} - -function useSwapDefaults(defaults: Partial = {}): SwapDefaults { - const tokenList = defaults.tokenList || DEFAULT_TOKEN_LIST - const input: DefaultTokenAmount = defaults.input || DEFAULT_INPUT - const output: DefaultTokenAmount = defaults.output || DEFAULT_OUTPUT - input.amount = input.amount || 0 - output.amount = output.amount || 0 - // eslint-disable-next-line react-hooks/exhaustive-deps - return useMemo(() => ({ tokenList, input, output }), []) -} - +export type DefaultAddress = string | { [chainId: number]: string | 'NATIVE' } | 'NATIVE' export interface SwapProps { + tokenList?: string | TokenInfo[] + defaultInputAddress?: DefaultAddress + defaultInputAmount?: string + defaultOutputAddress?: DefaultAddress + defaultOutputAmount?: string convenienceFee?: number - convenienceFeeRecipient?: string // TODO: improve typing to require recipient when fee is set - defaults?: Partial + convenienceFeeRecipient?: string | { [chainId: number]: string } } -export default function Swap({ defaults }: SwapProps) { - const { tokenList } = useSwapDefaults(defaults) - useTokenList(tokenList) +export default function Swap(props: SwapProps) { + useTokenList(props.tokenList) + useSwapDefaults(props) + const { active, account } = useActiveWeb3React() const [boundary, setBoundary] = useState(null) - const { chainId, active, account } = useActiveWeb3React() - - // Switch to on-chain currencies if/when chain changes to prevent chain mismatched currencies. - const [, updateSwapInputCurrency] = useSwapCurrency(Field.INPUT) - const [, updateSwapOutputCurrency] = useSwapCurrency(Field.OUTPUT) - const [, updateSwapInputAmount] = useSwapAmount(Field.INPUT) - useLayoutEffect(() => { - if (chainId) { - updateSwapInputCurrency(nativeOnChain(chainId)) - updateSwapOutputCurrency() - updateSwapInputAmount('') - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chainId]) return ( - <> +
Swap}> {active && } @@ -85,6 +52,6 @@ export default function Swap({ defaults }: SwapProps) { - + ) } diff --git a/src/lib/components/Widget.tsx b/src/lib/components/Widget.tsx index 8bc0bcce9e..ca8fef263b 100644 --- a/src/lib/components/Widget.tsx +++ b/src/lib/components/Widget.tsx @@ -6,13 +6,13 @@ import { UNMOUNTING } from 'lib/hooks/useUnmount' import { Provider as I18nProvider } from 'lib/i18n' import { MulticallUpdater, store as multicallStore } from 'lib/state/multicall' import styled, { keyframes, Theme, ThemeProvider } from 'lib/theme' -import { ComponentProps, JSXElementConstructor, PropsWithChildren, StrictMode, useRef } from 'react' +import { PropsWithChildren, StrictMode, useRef } from 'react' import { Provider as ReduxProvider } from 'react-redux' import { Provider as EthProvider } from 'widgets-web3-react/types' import { Provider as DialogProvider } from './Dialog' import ErrorBoundary, { ErrorHandler } from './Error/ErrorBoundary' -import ErrorGenerator from './Error/ErrorGenerator' +import WidgetPropValidator from './Error/WidgetsPropsValidator' import Web3Provider from './Web3Provider' const slideDown = keyframes` @@ -79,7 +79,7 @@ function Updaters() { ) } -export type WidgetProps | undefined = undefined> = { +export type WidgetProps = { theme?: Theme locale?: SupportedLocale provider?: EthProvider @@ -88,10 +88,7 @@ export type WidgetProps | undefined = undef dialog?: HTMLElement | null className?: string onError?: ErrorHandler -} & (T extends JSXElementConstructor - ? ComponentProps - : // eslint-disable-next-line @typescript-eslint/ban-types - {}) +} export default function Widget(props: PropsWithChildren) { const { @@ -105,6 +102,7 @@ export default function Widget(props: PropsWithChildren) { className, onError, } = props + const wrapper = useRef(null) return ( @@ -113,15 +111,16 @@ export default function Widget(props: PropsWithChildren) { - - - - - - {children} - - - + + + + + + {children} + + + + diff --git a/src/lib/hooks/swap/useSwapDefaults.ts b/src/lib/hooks/swap/useSwapDefaults.ts new file mode 100644 index 0000000000..edad2538f4 --- /dev/null +++ b/src/lib/hooks/swap/useSwapDefaults.ts @@ -0,0 +1,70 @@ +import { Currency } from '@uniswap/sdk-core' +import { nativeOnChain } from 'constants/tokens' +import { useUpdateAtom } from 'jotai/utils' +import { DefaultAddress } from 'lib/components/Swap' +import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' +import { useToken } from 'lib/hooks/useCurrency' +import { Field, Swap, swapAtom } from 'lib/state/swap' +import { useCallback, useLayoutEffect, useState } from 'react' + +function useDefaultToken( + defaultAddress: DefaultAddress | undefined, + chainId: number | undefined +): Currency | null | undefined { + let address = undefined + if (typeof defaultAddress === 'string') { + address = defaultAddress + } else if (typeof defaultAddress === 'object' && chainId) { + address = defaultAddress[chainId] + } + const token = useToken(address) + if (chainId && address === 'NATIVE') { + return nativeOnChain(chainId) + } + return token +} + +interface UseSwapDefaultsArgs { + defaultInputAddress?: DefaultAddress + defaultInputAmount?: string + defaultOutputAddress?: DefaultAddress + defaultOutputAmount?: string +} + +export default function useSwapDefaults({ + defaultInputAddress, + defaultInputAmount, + defaultOutputAddress, + defaultOutputAmount, +}: UseSwapDefaultsArgs) { + const updateSwap = useUpdateAtom(swapAtom) + const { chainId } = useActiveWeb3React() + const defaultInputToken = useDefaultToken(defaultInputAddress, chainId) + const defaultOutputToken = useDefaultToken(defaultOutputAddress, chainId) + + const setToDefaults = useCallback(() => { + const defaultSwapState: Swap = { + amount: '', + [Field.INPUT]: defaultInputToken || undefined, + [Field.OUTPUT]: defaultOutputToken || undefined, + independentField: Field.INPUT, + } + if (defaultInputAmount && defaultInputToken) { + defaultSwapState.amount = defaultInputAmount + } else if (defaultOutputAmount && defaultOutputToken) { + defaultSwapState.independentField = Field.OUTPUT + defaultSwapState.amount = defaultOutputAmount + } + updateSwap((swap) => ({ ...swap, ...defaultSwapState })) + }, [defaultInputToken, defaultOutputToken, defaultInputAmount, defaultOutputAmount, updateSwap]) + + const [previousChainId, setPreviousChainId] = useState(chainId) + useLayoutEffect(() => { + setPreviousChainId(chainId) + }, [chainId]) + useLayoutEffect(() => { + if (chainId && chainId !== previousChainId) { + setToDefaults() + } + }, [chainId, previousChainId, setToDefaults]) +} diff --git a/src/lib/index.tsx b/src/lib/index.tsx index 4c31928be6..15441c9078 100644 --- a/src/lib/index.tsx +++ b/src/lib/index.tsx @@ -1,9 +1,9 @@ -import Swap from './components/Swap' +import Swap, { SwapProps } from './components/Swap' import Widget, { WidgetProps } from './components/Widget' -export type SwapWidgetProps = WidgetProps +type SwapWidgetProps = SwapProps & WidgetProps -export function SwapWidget({ ...props }: SwapWidgetProps) { +export function SwapWidget(props: SwapWidgetProps) { return ( diff --git a/src/lib/state/swap.ts b/src/lib/state/swap.ts index 804467b4c2..260e59b0fc 100644 --- a/src/lib/state/swap.ts +++ b/src/lib/state/swap.ts @@ -11,9 +11,9 @@ export enum Field { export interface Swap { independentField: Field - readonly amount: string - readonly [Field.INPUT]?: Currency - readonly [Field.OUTPUT]?: Currency + amount: string + [Field.INPUT]?: Currency + [Field.OUTPUT]?: Currency integratorFee?: number }