diff --git a/package.json b/package.json index d196f896..4aa416d0 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "bignumber.js": "^9.1.1", "classnames": "^2.3.2", "debounce-promise": "^3.1.2", + "lodash.throttle": "^4.1.1", "moment": "^2.29.4", "next": "13.4.9", "react": "^18.2.0", @@ -46,6 +47,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", "@types/debounce-promise": "^3.1.6", + "@types/lodash.throttle": "^4.1.7", "@types/node": "^20.4.4", "@types/react": "18.2.14", "@types/react-dom": "18.2.7", diff --git a/src/components/RangeInput/InputOverlay.tsx b/src/components/RangeInput/InputOverlay.tsx index c99dc1d5..3af15fea 100644 --- a/src/components/RangeInput/InputOverlay.tsx +++ b/src/components/RangeInput/InputOverlay.tsx @@ -6,12 +6,13 @@ interface Props { max: number } +const THUMB_WIDTH = 33 + function InputOverlay({ max, value, marginThreshold }: Props) { - // 33 is the thumb width - const thumbPosPercent = 100 / (max / value) - const thumbPadRight = (thumbPosPercent / 100) * 33 + const thumbPosPercent = max === 0 ? 0 : 100 / (max / value) + const thumbPadRight = (thumbPosPercent / 100) * THUMB_WIDTH const markPosPercent = 100 / (max / (marginThreshold ?? 1)) - const markPadRight = (markPosPercent / 100) * 33 + const markPadRight = (markPosPercent / 100) * THUMB_WIDTH const hasPastMarginThreshold = marginThreshold ? value >= marginThreshold : undefined return ( diff --git a/src/components/RangeInput/index.tsx b/src/components/RangeInput/index.tsx index 410dd87f..365db867 100644 --- a/src/components/RangeInput/index.tsx +++ b/src/components/RangeInput/index.tsx @@ -3,6 +3,8 @@ import classNames from 'classnames' import InputOverlay from 'components/RangeInput/InputOverlay' +const LEFT_MARGIN = 5 + type Props = { max: number value: number @@ -23,6 +25,8 @@ function RangeInput(props: Props) { [onChange], ) + const markPosPercent = 100 / (max / (marginThreshold ?? 1)) + return (
- 0 + {markPosPercent > LEFT_MARGIN ? 0 : ''} {max.toFixed(2)}
diff --git a/src/components/Trade/TradeChart/OsmosisTheGraphDataFeed.ts b/src/components/Trade/TradeChart/OsmosisTheGraphDataFeed.ts index f83c701b..f04b6f57 100644 --- a/src/components/Trade/TradeChart/OsmosisTheGraphDataFeed.ts +++ b/src/components/Trade/TradeChart/OsmosisTheGraphDataFeed.ts @@ -202,8 +202,8 @@ export class OsmosisTheGraphDataFeed implements IDatafeedChartApi { body: JSON.stringify({ query }), }) .then((res) => res.json()) - .then((json: { data: { candles: BarQueryData[] } }) => { - return this.resolveBarData(json.data.candles, base) + .then((json: { data?: { candles: BarQueryData[] } }) => { + return this.resolveBarData(json.data?.candles || [], base) }) .catch((err) => { if (this.debug) console.error(err) diff --git a/src/components/Trade/TradeModule/SwapForm/MarginToggle.tsx b/src/components/Trade/TradeModule/SwapForm/MarginToggle.tsx index c80c0d49..0fd58301 100644 --- a/src/components/Trade/TradeModule/SwapForm/MarginToggle.tsx +++ b/src/components/Trade/TradeModule/SwapForm/MarginToggle.tsx @@ -7,6 +7,7 @@ import ConditionalWrapper from 'hocs/ConditionalWrapper' interface Props { checked: boolean onChange: (value: boolean) => void + borrowAssetSymbol: string disabled?: boolean } @@ -18,14 +19,24 @@ export default function MarginToggle(props: Props) { ( - Margin is not supported yet.}> + + {props.borrowAssetSymbol} is not a borrowable asset. Please choose another asset to + sell in order to margin trade. + + } + > {children} )} >
- + {props.disabled && ( + + )}
diff --git a/src/components/Trade/TradeModule/SwapForm/TradeSummary.tsx b/src/components/Trade/TradeModule/SwapForm/TradeSummary.tsx index ea8f8062..1431580e 100644 --- a/src/components/Trade/TradeModule/SwapForm/TradeSummary.tsx +++ b/src/components/Trade/TradeModule/SwapForm/TradeSummary.tsx @@ -1,18 +1,21 @@ import classNames from 'classnames' -import { useCallback, useMemo, useState } from 'react' +import { useMemo } from 'react' import ActionButton from 'components/Button/ActionButton' import useSwapRoute from 'hooks/useSwapRoute' import { getAssetByDenom } from 'utils/assets' import { hardcodedFee } from 'utils/constants' -import { formatAmountWithSymbol } from 'utils/formatters' +import { formatAmountWithSymbol, formatPercent } from 'utils/formatters' interface Props { buyAsset: Asset sellAsset: Asset + borrowRate?: number | null buyButtonDisabled: boolean containerClassName?: string showProgressIndicator: boolean + isMargin?: boolean + borrowAmount: BigNumber buyAction: () => void } @@ -20,9 +23,12 @@ export default function TradeSummary(props: Props) { const { buyAsset, sellAsset, + borrowRate, buyAction, buyButtonDisabled, containerClassName, + isMargin, + borrowAmount, showProgressIndicator, } = props const { data: routes, isLoading: isRouteLoading } = useSwapRoute(sellAsset.denom, buyAsset.denom) @@ -49,6 +55,24 @@ export default function TradeSummary(props: Props) { Fees {formatAmountWithSymbol(hardcodedFee.amount[0])} + {isMargin && ( + <> +
+ Borrowing + + {formatAmountWithSymbol({ + denom: sellAsset.denom, + amount: borrowAmount.toString(), + })} + +
+
+ Borrow rate + {formatPercent(borrowRate || 0)} +
+ + )} +
Route {parsedRoutes} diff --git a/src/components/Trade/TradeModule/SwapForm/index.tsx b/src/components/Trade/TradeModule/SwapForm/index.tsx index 7df96b7e..6caa40ea 100644 --- a/src/components/Trade/TradeModule/SwapForm/index.tsx +++ b/src/components/Trade/TradeModule/SwapForm/index.tsx @@ -8,10 +8,10 @@ import useCurrentAccount from 'hooks/useCurrentAccount' import useLocalStorage from 'hooks/useLocalStorage' import usePrices from 'hooks/usePrices' import useStore from 'store' -import { byDenom } from 'utils/array' +import { byDenom, byTokenDenom } from 'utils/array' import { hardcodedFee } from 'utils/constants' import RangeInput from 'components/RangeInput' -import { BN } from 'utils/helpers' +import { asyncThrottle, BN } from 'utils/helpers' import AssetAmountInput from 'components/Trade/TradeModule/SwapForm/AssetAmountInput' import MarginToggle from 'components/Trade/TradeModule/SwapForm/MarginToggle' import OrderTypeSelector from 'components/Trade/TradeModule/SwapForm/OrderTypeSelector' @@ -20,6 +20,7 @@ import TradeSummary from 'components/Trade/TradeModule/SwapForm/TradeSummary' import { BNCoin } from 'types/classes/BNCoin' import estimateExactIn from 'api/swap/estimateExactIn' import useHealthComputer from 'hooks/useHealthComputer' +import useMarketBorrowings from 'hooks/useMarketBorrowings' interface Props { buyAsset: Asset @@ -33,20 +34,77 @@ export default function SwapForm(props: Props) { const swap = useStore((s) => s.swap) const [slippage] = useLocalStorage(SLIPPAGE_KEY, DEFAULT_SETTINGS.slippage) const { computeMaxSwapAmount } = useHealthComputer(account) - const buyAmountEstimationTimeout = useRef(undefined) + const { data: borrowAssets } = useMarketBorrowings() const [isMarginChecked, setMarginChecked] = useState(false) const [buyAssetAmount, setBuyAssetAmount] = useState(BN_ZERO) const [sellAssetAmount, setSellAssetAmount] = useState(BN_ZERO) - const focusedInput = useRef<'buy' | 'sell' | null>(null) const [maxBuyableAmountEstimation, setMaxBuyableAmountEstimation] = useState(BN_ZERO) const [selectedOrderType, setSelectedOrderType] = useState('Market') - const [isTransactionExecuting, setTransactionExecuting] = useState(false) + const [isConfirming, setIsConfirming] = useState(false) - const [maxSellAssetAmount, maxSellAssetAmountStr] = useMemo(() => { - const amount = computeMaxSwapAmount(sellAsset.denom, buyAsset.denom, 'default') - return [amount, amount.toString()] - }, [computeMaxSwapAmount, sellAsset.denom, buyAsset.denom]) + const throttledEstimateExactIn = useMemo(() => asyncThrottle(estimateExactIn, 250), []) + + const borrowAsset = useMemo( + () => borrowAssets.find(byDenom(sellAsset.denom)), + [borrowAssets, sellAsset.denom], + ) + + const onChangeSellAmount = useCallback( + (amount: BigNumber) => { + setSellAssetAmount(amount) + throttledEstimateExactIn( + { denom: sellAsset.denom, amount: amount.toString() }, + buyAsset.denom, + ).then(setBuyAssetAmount) + }, + [sellAsset.denom, throttledEstimateExactIn, buyAsset.denom], + ) + + const onChangeBuyAmount = useCallback( + (amount: BigNumber) => { + setBuyAssetAmount(amount) + const swapFrom = { + denom: buyAsset.denom, + amount: amount.toString(), + } + throttledEstimateExactIn(swapFrom, sellAsset.denom).then(setSellAssetAmount) + }, + [buyAsset.denom, throttledEstimateExactIn, sellAsset.denom], + ) + + const handleRangeInputChange = useCallback( + (value: number) => { + onChangeSellAmount(BN(value).shiftedBy(sellAsset.decimals).integerValue()) + }, + [sellAsset.decimals, onChangeSellAmount], + ) + + const [maxSellAmount, marginThreshold] = useMemo(() => { + const maxAmount = computeMaxSwapAmount(sellAsset.denom, buyAsset.denom, 'default') + const maxAmountOnMargin = computeMaxSwapAmount(sellAsset.denom, buyAsset.denom, 'margin') + + estimateExactIn( + { + denom: sellAsset.denom, + amount: (isMarginChecked ? maxAmountOnMargin : maxAmount).toString(), + }, + buyAsset.denom, + ).then(setMaxBuyableAmountEstimation) + + if (isMarginChecked) return [maxAmountOnMargin, maxAmount] + + if (sellAssetAmount.isGreaterThan(maxAmount)) onChangeSellAmount(maxAmount) + + return [maxAmount, maxAmount] + }, [ + computeMaxSwapAmount, + sellAsset.denom, + buyAsset.denom, + isMarginChecked, + onChangeSellAmount, + sellAssetAmount, + ]) const [buyAssetValue, sellAssetValue] = useMemo(() => { const buyAssetPrice = prices.find(byDenom(buyAsset.denom))?.amount ?? BN_ZERO @@ -67,55 +125,22 @@ export default function SwapForm(props: Props) { ]) useEffect(() => { - focusedInput.current = null setBuyAssetAmount(BN_ZERO) setSellAssetAmount(BN_ZERO) }, [buyAsset.denom, sellAsset.denom]) - useEffect(() => { - estimateExactIn({ denom: sellAsset.denom, amount: maxSellAssetAmountStr }, buyAsset.denom).then( - setMaxBuyableAmountEstimation, - ) - }, [maxSellAssetAmountStr, buyAsset.denom, sellAsset.denom]) - - useEffect(() => { - if (focusedInput.current === 'sell') { - if (buyAmountEstimationTimeout.current) { - clearTimeout(buyAmountEstimationTimeout.current) - } - - buyAmountEstimationTimeout.current = setTimeout( - () => - estimateExactIn( - { denom: sellAsset.denom, amount: sellAssetAmount.toString() }, - buyAsset.denom, - ) - .then(setBuyAssetAmount) - .then(() => clearTimeout(buyAmountEstimationTimeout?.current)), - 250, - ) - } - }, [buyAsset.denom, focusedInput, sellAsset.denom, sellAssetAmount]) - - useEffect(() => { - if (focusedInput.current === 'buy') { - estimateExactIn( - { - denom: buyAsset.denom, - amount: buyAssetAmount.toString(), - }, - sellAsset.denom, - ).then(setSellAssetAmount) - } - }, [buyAsset.denom, buyAssetAmount, focusedInput, sellAsset.denom]) - const handleBuyClick = useCallback(async () => { if (account?.id) { - setTransactionExecuting(true) + setIsConfirming(true) + const borrowCoin = sellAssetAmount.isGreaterThan(marginThreshold) + ? BNCoin.fromDenomAndBigNumber(sellAsset.denom, sellAssetAmount.minus(marginThreshold)) + : undefined + const isSucceeded = await swap({ fee: hardcodedFee, accountId: account.id, coinIn: BNCoin.fromDenomAndBigNumber(sellAsset.denom, sellAssetAmount.integerValue()), + borrow: borrowCoin, denomOut: buyAsset.denom, slippage, }) @@ -123,72 +148,78 @@ export default function SwapForm(props: Props) { setSellAssetAmount(BN_ZERO) setBuyAssetAmount(BN_ZERO) } - setTransactionExecuting(false) + setIsConfirming(false) } - }, [account?.id, buyAsset.denom, sellAsset.denom, sellAssetAmount, slippage, swap]) - - const dismissInputFocus = useCallback(() => (focusedInput.current = null), []) - - const handleRangeInputChange = useCallback( - (value: number) => { - focusedInput.current = 'sell' - setSellAssetAmount(BN(value).shiftedBy(sellAsset.decimals).integerValue()) - }, - [sellAsset.decimals], - ) + }, [ + account?.id, + buyAsset.denom, + sellAsset.denom, + sellAssetAmount, + slippage, + swap, + marginThreshold, + ]) return ( <> - - + - (focusedInput.current = 'buy')} - disabled={isTransactionExecuting} + disabled={isConfirming} /> (focusedInput.current = 'sell')} - disabled={isTransactionExecuting} + disabled={isConfirming} /> ) diff --git a/src/components/Wallet/WalletConnecting.tsx b/src/components/Wallet/WalletConnecting.tsx index f16832ed..c39a3dd4 100644 --- a/src/components/Wallet/WalletConnecting.tsx +++ b/src/components/Wallet/WalletConnecting.tsx @@ -1,6 +1,6 @@ import { CosmWasmClient } from '@cosmjs/cosmwasm-stargate' import { useShuttle } from '@delphi-labs/shuttle-react' -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' import { CircularProgress } from 'components/CircularProgress' import FullOverlayContent from 'components/FullOverlayContent' @@ -35,48 +35,64 @@ export default function WalletConnecting(props: Props) { const providerId = props.providerId ?? recentWallet?.providerId const isAutoConnect = props.autoConnect - const handleConnect = async (extensionProviderId: string) => { - setIsConnecting(true) + const handleConnect = useCallback( + (extensionProviderId: string) => { + async function handleConnectAsync() { + setIsConnecting(true) - try { - const provider = extensionProviders.find((p) => p.id === providerId) - const response = - isAutoConnect && provider - ? await provider.connect({ chainId: currentChainId }) - : await connect({ extensionProviderId, chainId: currentChainId }) - const cosmClient = await CosmWasmClient.connect(response.network.rpc) - const walletClient: WalletClient = { - broadcast, - cosmWasmClient: cosmClient, - connectedWallet: response, - sign, - simulate, + try { + const provider = extensionProviders.find((p) => p.id === providerId) + const response = + isAutoConnect && provider + ? await provider.connect({ chainId: currentChainId }) + : await connect({ extensionProviderId, chainId: currentChainId }) + const cosmClient = await CosmWasmClient.connect(response.network.rpc) + const walletClient: WalletClient = { + broadcast, + cosmWasmClient: cosmClient, + connectedWallet: response, + sign, + simulate, + } + setIsConnecting(false) + useStore.setState({ + client: walletClient, + address: response.account.address, + focusComponent: , + }) + } catch (error) { + if (error instanceof Error) { + setIsConnecting(false) + useStore.setState({ + client: undefined, + address: undefined, + accounts: null, + focusComponent: ( + + ), + }) + } + } } - setIsConnecting(false) - useStore.setState({ - client: walletClient, - address: response.account.address, - focusComponent: , - }) - } catch (error) { - if (error instanceof Error) { - setIsConnecting(false) - useStore.setState({ - client: undefined, - address: undefined, - accounts: null, - focusComponent: ( - - ), - }) - } - } - } + + handleConnectAsync() + }, + [ + broadcast, + connect, + extensionProviders, + isAutoConnect, + providerId, + setIsConnecting, + sign, + simulate, + ], + ) useEffect(() => { if (isConnecting || !providerId) return diff --git a/src/hooks/useHealthComputer.tsx b/src/hooks/useHealthComputer.tsx index bbcd4119..93ca9b91 100644 --- a/src/hooks/useHealthComputer.tsx +++ b/src/hooks/useHealthComputer.tsx @@ -43,57 +43,66 @@ export default function useHealthComputer(account?: Account) { const vaultPositionValues = useMemo(() => { if (!account?.vaults) return null - return account.vaults.reduce((prev, curr) => { - const baseCoinPrice = prices.find((price) => price.denom === curr.denoms.lp)?.amount || 0 - prev[curr.address] = { - base_coin: { - amount: '0', // Not used by healthcomputer - denom: curr.denoms.lp, - value: curr.amounts.unlocking.times(baseCoinPrice).integerValue().toString(), - }, - vault_coin: { - amount: '0', // Not used by healthcomputer - denom: curr.denoms.vault, - value: curr.values.primary - .div(baseCurrencyPrice) - .plus(curr.values.secondary.div(baseCurrencyPrice)) - .integerValue() - .toString(), - }, - } - return prev - }, {} as { [key: string]: VaultPositionValue }) + return account.vaults.reduce( + (prev, curr) => { + const baseCoinPrice = prices.find((price) => price.denom === curr.denoms.lp)?.amount || 0 + prev[curr.address] = { + base_coin: { + amount: '0', // Not used by healthcomputer + denom: curr.denoms.lp, + value: curr.amounts.unlocking.times(baseCoinPrice).integerValue().toString(), + }, + vault_coin: { + amount: '0', // Not used by healthcomputer + denom: curr.denoms.vault, + value: curr.values.primary + .div(baseCurrencyPrice) + .plus(curr.values.secondary.div(baseCurrencyPrice)) + .integerValue() + .toString(), + }, + } + return prev + }, + {} as { [key: string]: VaultPositionValue }, + ) }, [account?.vaults, prices, baseCurrencyPrice]) const priceData = useMemo(() => { const baseCurrencyPrice = prices.find((price) => price.denom === baseCurrency.denom)?.amount || 0 - return prices.reduce((prev, curr) => { - prev[curr.denom] = curr.amount.div(baseCurrencyPrice).decimalPlaces(18).toString() - return prev - }, {} as { [key: string]: string }) + return prices.reduce( + (prev, curr) => { + prev[curr.denom] = curr.amount.div(baseCurrencyPrice).decimalPlaces(18).toString() + return prev + }, + {} as { [key: string]: string }, + ) }, [prices, baseCurrency.denom]) const denomsData = useMemo( () => - assetParams.reduce((prev, curr) => { - const params: AssetParamsBaseForAddr = { - ...curr, - // The following overrides are required as testnet is 'broken' and new contracts are not updated yet - // These overrides are not used by the healthcomputer internally, so they're not important anyways. - protocol_liquidation_fee: '1', - liquidation_bonus: { - max_lb: '1', - min_lb: '1', - slope: '1', - starting_lb: '1', - }, - } - prev[params.denom] = params + assetParams.reduce( + (prev, curr) => { + const params: AssetParamsBaseForAddr = { + ...curr, + // The following overrides are required as testnet is 'broken' and new contracts are not updated yet + // These overrides are not used by the healthcomputer internally, so they're not important anyways. + protocol_liquidation_fee: '1', + liquidation_bonus: { + max_lb: '1', + min_lb: '1', + slope: '1', + starting_lb: '1', + }, + } + prev[params.denom] = params - return prev - }, {} as { [key: string]: AssetParamsBaseForAddr }), + return prev + }, + {} as { [key: string]: AssetParamsBaseForAddr }, + ), [assetParams], ) @@ -103,10 +112,13 @@ export default function useHealthComputer(account?: Account) { const vaultPositionDenoms = positions.vaults.map((vault) => vault.vault.address) return vaultConfigs .filter((config) => vaultPositionDenoms.includes(config.addr)) - .reduce((prev, curr) => { - prev[curr.addr] = curr - return prev - }, {} as { [key: string]: VaultConfigBaseForString }) + .reduce( + (prev, curr) => { + prev[curr.addr] = curr + return prev + }, + {} as { [key: string]: VaultConfigBaseForString }, + ) }, [vaultConfigs, positions]) const healthComputer: HealthComputer | null = useMemo(() => { diff --git a/src/store/slices/broadcast.ts b/src/store/slices/broadcast.ts index 619b9cf8..4938d919 100644 --- a/src/store/slices/broadcast.ts +++ b/src/store/slices/broadcast.ts @@ -363,6 +363,7 @@ export default function createBroadcastSlice( fee: StdFee accountId: string coinIn: BNCoin + borrow?: BNCoin denomOut: string slippage: number }) => { @@ -370,6 +371,7 @@ export default function createBroadcastSlice( update_credit_account: { account_id: options.accountId, actions: [ + ...(options.borrow ? [{ borrow: options.borrow.toCoin() }] : []), { swap_exact_in: { coin_in: options.coinIn.toActionCoin(), diff --git a/src/types/interfaces/store/broadcast.d.ts b/src/types/interfaces/store/broadcast.d.ts index e952ec31..a0aa113e 100644 --- a/src/types/interfaces/store/broadcast.d.ts +++ b/src/types/interfaces/store/broadcast.d.ts @@ -61,6 +61,7 @@ interface BroadcastSlice { fee: StdFee accountId: string coinIn: BNCoin + borrow: BNCoin denomOut: string slippage: number }) => Promise diff --git a/src/utils/accounts.ts b/src/utils/accounts.ts index a0fe4b18..58674b75 100644 --- a/src/utils/accounts.ts +++ b/src/utils/accounts.ts @@ -100,7 +100,7 @@ export function convertAccountToPositions(account: Account): Positions { ], }, }, - } as VaultPosition), + }) as VaultPosition, ), } } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index c5b23cf2..3462231a 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,4 +1,5 @@ import BigNumber from 'bignumber.js' +import throttle from 'lodash.throttle' BigNumber.config({ EXPONENTIAL_AT: 1e9 }) export function BN(n: BigNumber.Value) { @@ -10,3 +11,15 @@ export function getApproximateHourlyInterest(amount: string, borrowRate: number) .dividedBy(24 * 365) .multipliedBy(amount) } + +export function asyncThrottle Promise>(func: F, wait?: number) { + const throttled = throttle((resolve, reject, args: Parameters) => { + func(...args) + .then(resolve) + .catch(reject) + }, wait) + return (...args: Parameters): ReturnType => + new Promise((resolve, reject) => { + throttled(resolve, reject, args) + }) as ReturnType +} diff --git a/yarn.lock b/yarn.lock index ddd292f8..27059d50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3564,6 +3564,13 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/lodash.throttle@^4.1.7": + version "4.1.7" + resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.7.tgz#4ef379eb4f778068022310ef166625f420b6ba58" + integrity sha512-znwGDpjCHQ4FpLLx19w4OXDqq8+OvREa05H89obtSyXyOFKL3dDjCslsmfBz0T2FU8dmf5Wx1QvogbINiGIu9g== + dependencies: + "@types/lodash" "*" + "@types/lodash.values@^4.3.6": version "4.3.7" resolved "https://registry.npmjs.org/@types/lodash.values/-/lodash.values-4.3.7.tgz" @@ -7317,6 +7324,11 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== +lodash.throttle@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" + integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== + lodash.values@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz"