From 775a5730d8afc9a04743d3fbbe251271e373d373 Mon Sep 17 00:00:00 2001 From: 0xean <0xean.eth@gmail.com> Date: Wed, 17 Jan 2024 13:36:44 -0400 Subject: [PATCH 1/3] chore: add risk comments (#6029) --- .github/PULL_REQUEST_TEMPLATE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e1338a5c7b9..ac3b975e9fe 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -17,10 +17,13 @@ If applicable, please link to the github issue and put `closes #XXXX` in your co closes # ## Risk +> High Risk PRs Require 2 approvals From 502665343394a19b9e1df643541e06e9848ede00 Mon Sep 17 00:00:00 2001 From: woody <125113430+woodenfurniture@users.noreply.github.com> Date: Thu, 18 Jan 2024 09:09:20 +1100 Subject: [PATCH 2/3] perf: simplify swapper asset sorting for faster boot times (#6007) * perf: simplify swapper asset sorting for faster boot times * perf: use simple alpha sorting for assets while market data is loading * perf: dont reload the supported asset list when the asset list order changes * fix: ensure asset list order reacts to incoming updates --- .../hooks/useSupportedAssets.tsx | 54 +++++++++++-------- src/state/apis/swappers/swappersApi.ts | 23 +++----- src/state/slices/common-selectors.ts | 5 ++ src/state/slices/marketDataSlice/selectors.ts | 6 +++ 4 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/components/MultiHopTrade/hooks/useSupportedAssets.tsx b/src/components/MultiHopTrade/hooks/useSupportedAssets.tsx index 0dc8cf32336..0ca7520b448 100644 --- a/src/components/MultiHopTrade/hooks/useSupportedAssets.tsx +++ b/src/components/MultiHopTrade/hooks/useSupportedAssets.tsx @@ -1,43 +1,53 @@ -import { KnownChainIds } from '@shapeshiftoss/types' import { useMemo } from 'react' -import { useIsSnapInstalled } from 'hooks/useIsSnapInstalled/useIsSnapInstalled' -import { useWallet } from 'hooks/useWallet/useWallet' -import { walletSupportsChain } from 'hooks/useWalletSupportsChain/useWalletSupportsChain' -import { isSome } from 'lib/utils' import { useGetSupportedAssetsQuery } from 'state/apis/swappers/swappersApi' -import { selectAssetsSortedByMarketCapUserCurrencyBalanceAndName } from 'state/slices/common-selectors' -import { selectAssets } from 'state/slices/selectors' +import { + selectAssetsSortedByMarketCapUserCurrencyBalanceAndName, + selectAssetsSortedByName, + selectWalletSupportedChainIds, +} from 'state/slices/common-selectors' +import { selectMarketDataDidLoad } from 'state/slices/selectors' import { useAppSelector } from 'state/store' export const useSupportedAssets = () => { - const sortedAssets = useAppSelector(selectAssetsSortedByMarketCapUserCurrencyBalanceAndName) - const assets = useAppSelector(selectAssets) - const wallet = useWallet().state.wallet - const isSnapInstalled = useIsSnapInstalled() + const marketDataDidLoad = useAppSelector(selectMarketDataDidLoad) + const assetsSortedByName = useAppSelector(selectAssetsSortedByName) + const assetsSortedByMarketCapUserCurrencyBalanceAndName = useAppSelector( + selectAssetsSortedByMarketCapUserCurrencyBalanceAndName, + ) + + const sortedAssets = useMemo(() => { + // if the market data has not yet loaded once, return a simplified sorting of assets + if (!marketDataDidLoad) { + return assetsSortedByName + } else { + return assetsSortedByMarketCapUserCurrencyBalanceAndName + } + }, [assetsSortedByMarketCapUserCurrencyBalanceAndName, assetsSortedByName, marketDataDidLoad]) + + const walletSupportedChainIds = useAppSelector(selectWalletSupportedChainIds) const queryParams = useMemo(() => { return { - walletSupportedChainIds: Object.values(KnownChainIds).filter(chainId => - walletSupportsChain({ chainId, wallet, isSnapInstalled }), - ), - sortedAssetIds: sortedAssets.map(asset => asset.assetId), + walletSupportedChainIds, } - }, [isSnapInstalled, sortedAssets, wallet]) + }, [walletSupportedChainIds]) - const { data, isLoading } = useGetSupportedAssetsQuery(queryParams) + const { data, isFetching } = useGetSupportedAssetsQuery(queryParams) const supportedSellAssets = useMemo(() => { if (!data) return [] - return data.supportedSellAssetIds.map(assetId => assets[assetId]).filter(isSome) - }, [assets, data]) + const assetIdsSet = new Set(data.supportedSellAssetIds) + return sortedAssets.filter(({ assetId }) => assetIdsSet.has(assetId)) + }, [data, sortedAssets]) const supportedBuyAssets = useMemo(() => { if (!data) return [] - return data.supportedBuyAssetIds.map(assetId => assets[assetId]).filter(isSome) - }, [assets, data]) + const assetIdsSet = new Set(data.supportedBuyAssetIds) + return sortedAssets.filter(({ assetId }) => assetIdsSet.has(assetId)) + }, [data, sortedAssets]) return { - isLoading, + isLoading: isFetching, supportedSellAssets, supportedBuyAssets, } diff --git a/src/state/apis/swappers/swappersApi.ts b/src/state/apis/swappers/swappersApi.ts index 2e925380b0a..61cd050d84c 100644 --- a/src/state/apis/swappers/swappersApi.ts +++ b/src/state/apis/swappers/swappersApi.ts @@ -219,13 +219,10 @@ export const swappersApi = createApi({ supportedSellAssetIds: AssetId[] supportedBuyAssetIds: AssetId[] }, - { walletSupportedChainIds: ChainId[]; sortedAssetIds: AssetId[] } + { walletSupportedChainIds: ChainId[] } >({ queryFn: async ( - { - walletSupportedChainIds, - sortedAssetIds, - }: { walletSupportedChainIds: ChainId[]; sortedAssetIds: AssetId[] }, + { walletSupportedChainIds }: { walletSupportedChainIds: ChainId[] }, { getState }, ) => { const state = getState() as ReduxState @@ -236,12 +233,10 @@ export const swappersApi = createApi({ const sellAsset = selectSellAsset(state) const supportedSellAssetsSet = await getSupportedSellAssetIds(enabledSwappers, assets) - const supportedSellAssetIds = sortedAssetIds - .filter(assetId => supportedSellAssetsSet.has(assetId)) - .filter(assetId => { - const chainId = fromAssetId(assetId).chainId - return walletSupportedChainIds.includes(chainId) - }) + const supportedSellAssetIds = Array.from(supportedSellAssetsSet).filter(assetId => { + const chainId = fromAssetId(assetId).chainId + return walletSupportedChainIds.includes(chainId) + }) const supportedBuyAssetsSet = await getSupportedBuyAssetIds( enabledSwappers, @@ -249,14 +244,10 @@ export const swappersApi = createApi({ assets, ) - const supportedBuyAssetIds = sortedAssetIds.filter(assetId => - supportedBuyAssetsSet.has(assetId), - ) - return { data: { supportedSellAssetIds, - supportedBuyAssetIds, + supportedBuyAssetIds: Array.from(supportedBuyAssetsSet), }, } }, diff --git a/src/state/slices/common-selectors.ts b/src/state/slices/common-selectors.ts index 9a70a1c3615..7a6959b89d5 100644 --- a/src/state/slices/common-selectors.ts +++ b/src/state/slices/common-selectors.ts @@ -164,3 +164,8 @@ export const selectAssetsSortedByMarketCapUserCurrencyBalanceAndName = ) }, ) + +export const selectAssetsSortedByName = createDeepEqualOutputSelector(selectAssets, assets => { + const getAssetName = (asset: Asset) => asset.name + return orderBy(Object.values(assets).filter(isSome), [getAssetName], ['asc']) +}) diff --git a/src/state/slices/marketDataSlice/selectors.ts b/src/state/slices/marketDataSlice/selectors.ts index 97738137b5a..6313fd74656 100644 --- a/src/state/slices/marketDataSlice/selectors.ts +++ b/src/state/slices/marketDataSlice/selectors.ts @@ -1,4 +1,5 @@ import { createSelector } from '@reduxjs/toolkit' +import { QueryStatus } from '@reduxjs/toolkit/dist/query' import type { AssetId } from '@shapeshiftoss/caip' import type { HistoryData, HistoryTimeframe, MarketData } from '@shapeshiftoss/types' import createCachedSelector from 're-reselect' @@ -13,6 +14,11 @@ import { selectSelectedCurrency } from 'state/slices/preferencesSlice/selectors' import { defaultMarketData } from './marketDataSlice' import type { MarketDataById } from './types' +export const selectMarketDataDidLoad = (state: ReduxState) => + Object.values(state.marketApi.queries).some( + query => query?.endpointName === 'findAll' && query?.status === QueryStatus.fulfilled, + ) + // TODO(woodenfurniture): rename this to clarify that prices are in USD not fiat export const selectCryptoMarketData = ((state: ReduxState) => state.marketData.crypto.byId) as ( state: ReduxState, From 0e386482a56cbf803173f347ba04485fd36a6ec4 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 17 Jan 2024 23:28:56 +0100 Subject: [PATCH 3/3] feat: standalone "Add Liquidity" (#6028) * fix: default * fix: selection * feat: set asymSide * fix: don't hide deposit type options when standalone * fix: hide radio options on pool page * fix: types * feat: cleanup * feat: more cleanup * feat: more more cleanup * fix: page refresh * fix: selectPair should be rendered conditionally as well * fix: trade asset inputs on pool page * fix: use buyAssetSerach --------- Co-authored-by: Apotheosis <0xapotheosis@gmail.com> Co-authored-by: Apotheosis <97164662+0xApotheosis@users.noreply.github.com> --- .../AddLiquitity/AddLiquidityInput.tsx | 149 ++++++++++++++---- .../AddLiquitity/components/DepositType.tsx | 19 +-- 2 files changed, 132 insertions(+), 36 deletions(-) diff --git a/src/pages/ThorChainLP/components/AddLiquitity/AddLiquidityInput.tsx b/src/pages/ThorChainLP/components/AddLiquitity/AddLiquidityInput.tsx index 55b806ce386..8a2c2910284 100644 --- a/src/pages/ThorChainLP/components/AddLiquitity/AddLiquidityInput.tsx +++ b/src/pages/ThorChainLP/components/AddLiquitity/AddLiquidityInput.tsx @@ -19,7 +19,7 @@ import { import { thorchainAssetId } from '@shapeshiftoss/caip' import type { Asset, MarketData } from '@shapeshiftoss/types' import prettyMilliseconds from 'pretty-ms' -import React, { useCallback, useEffect, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { BiSolidBoltCircle } from 'react-icons/bi' import { FaPlus } from 'react-icons/fa6' import { useTranslate } from 'react-polyglot' @@ -32,12 +32,14 @@ import { Row } from 'components/Row/Row' import { SlideTransition } from 'components/SlideTransition' import { RawText } from 'components/Text' import { useBrowserRouter } from 'hooks/useBrowserRouter/useBrowserRouter' +import { useModal } from 'hooks/useModal/useModal' import { bn, bnOrZero, convertPrecision } from 'lib/bignumber/bignumber' +import { isSome } from 'lib/utils' import { THOR_PRECISION } from 'lib/utils/thorchain/constants' import { estimateAddThorchainLiquidityPosition } from 'lib/utils/thorchain/lp' import { usePools } from 'pages/ThorChainLP/hooks/usePools' import { AsymSide } from 'pages/ThorChainLP/hooks/useUserLpData' -import { selectAssetById, selectMarketDataById } from 'state/slices/selectors' +import { selectAssetById, selectAssets, selectMarketDataById } from 'state/slices/selectors' import { useAppSelector } from 'state/store' import type { AddLiquidityProps } from './AddLiquidity' @@ -72,12 +74,70 @@ export const AddLiquidityInput: React.FC = ({ const { data: parsedPools } = usePools() - const foundPool = useMemo(() => { + const assets = useAppSelector(selectAssets) + const poolAssets = useMemo(() => { + if (!parsedPools) return [] + + return [...new Set(parsedPools.map(pool => assets[pool.assetId]).filter(isSome))] + }, [assets, parsedPools]) + + // TODO(gomes): Even though that's an edge case for users, and a bad practice, handling sym and asymm positions simultaneously + // *is* possible and *is* something that both we and TS do. We can do one better than TS here however: + // - When a user deposits symetrically, they can then deposit asymetrically, but only on the asset side + // - When a user deposits asymetrically, no matter the side, they *can* deposit symetrically on the other side + // - They can also deposit asymetrically after that, but with one caveat: they can do so only if they deposited asym on the *asset* side only + // In other words, if they have an active asym. RUNE position, they can't deposit symetrically after that unless they withdraw + // The reason for that is that the RUNE side memo performs a nameservice operation, registering the asset address (or a placeholder) + // + // We should handle this in the UI and block users from deposits that *will* fail, by detecting their current position(s) + // and not allowing them to select the sure-to-fail deposit types + const defaultOpportunityId = useMemo(() => { if (!parsedPools) return undefined + if (opportunityId) return undefined + + const firstAsymOpportunityId = parsedPools.find(pool => pool.asymSide === null)?.opportunityId - return parsedPools.find(pool => pool.opportunityId === opportunityId) + return firstAsymOpportunityId }, [opportunityId, parsedPools]) + const [activeOpportunityId, setActiveOpportunityId] = useState( + opportunityId ?? defaultOpportunityId, + ) + + useEffect(() => { + if (!(opportunityId || defaultOpportunityId)) return + + setActiveOpportunityId(opportunityId ?? defaultOpportunityId) + }, [defaultOpportunityId, opportunityId]) + + const foundPool = useMemo(() => { + if (!parsedPools) return undefined + + return parsedPools.find(pool => pool.opportunityId === activeOpportunityId) + }, [activeOpportunityId, parsedPools]) + + const _asset = useAppSelector(state => selectAssetById(state, foundPool?.assetId ?? '')) + useEffect(() => { + if (!_asset) return + setAsset(_asset) + }, [_asset]) + + const rune = useAppSelector(state => selectAssetById(state, thorchainAssetId)) + + const [asset, setAsset] = useState(_asset) + + useEffect(() => { + if (!(asset && parsedPools)) return + // We only want to run this effect in the standalone AddLiquidity page + if (!defaultOpportunityId) return + + const foundOpportunityId = (parsedPools ?? []).find( + pool => pool.assetId === asset.assetId && pool.asymSide === null, + )?.opportunityId + if (!foundOpportunityId) return + setActiveOpportunityId(foundOpportunityId) + }, [asset, defaultOpportunityId, parsedPools]) + const handleAssetChange = useCallback((asset: Asset) => { console.info(asset) }, []) @@ -134,9 +194,7 @@ export const AddLiquidityInput: React.FC = ({ ) }, [backIcon, handleBackClick, headerComponent, translate]) - const asset = useAppSelector(state => selectAssetById(state, foundPool?.assetId ?? '')) const assetMarketData = useAppSelector(state => selectMarketDataById(state, asset?.assetId ?? '')) - const rune = useAppSelector(state => selectAssetById(state, thorchainAssetId)) const runeMarketData = useAppSelector(state => selectMarketDataById(state, rune?.assetId ?? '')) const [assetCryptoLiquidityAmount, setAssetCryptoLiquidityAmount] = React.useState< @@ -284,43 +342,80 @@ export const AddLiquidityInput: React.FC = ({ ) }, [asset, foundPool, rune, translate]) + const buyAssetSearch = useModal('buyAssetSearch') + const handlePoolAssetClick = useCallback(() => { + buyAssetSearch.open({ + onClick: setAsset, + title: 'pools.pool', + assets: poolAssets, + }) + }, [buyAssetSearch, poolAssets]) + + const pairSelect = useMemo(() => { + // We only want to show the pair select on standalone "Add Liquidity" - not on the pool page + if (!defaultOpportunityId) return null + return ( + <> + + {translate('pools.selectPair')} + + + + + ) + }, [asset?.assetId, defaultOpportunityId, handleAssetChange, handlePoolAssetClick, translate]) + + const handleAsymSideChange = useCallback( + (asymSide: string | null) => { + if (!(parsedPools && asset)) return + + // The null option gets casted as an empty string by the radio component so we cast it back to null + const parsedAsymSide = (asymSide as AsymSide | '') || null + const assetPools = parsedPools.filter(pool => pool.assetId === asset.assetId) + const foundPool = assetPools.find(pool => pool.asymSide === parsedAsymSide) + if (!foundPool) return + + setActiveOpportunityId(foundPool.opportunityId) + }, + [asset, parsedPools], + ) + if (!foundPool || !asset || !rune) return null return ( {renderHeader} - - - {translate('pools.selectPair')} - - - - + {pairSelect} {translate('pools.depositAmounts')} - + {tradeAssetInputs} diff --git a/src/pages/ThorChainLP/components/AddLiquitity/components/DepositType.tsx b/src/pages/ThorChainLP/components/AddLiquitity/components/DepositType.tsx index 3f681a60283..d3b71acc407 100644 --- a/src/pages/ThorChainLP/components/AddLiquitity/components/DepositType.tsx +++ b/src/pages/ThorChainLP/components/AddLiquitity/components/DepositType.tsx @@ -75,12 +75,14 @@ const options = [ type DepositTypeProps = { assetId: AssetId - // If undefined/not passed, we're not locking the user in any kind of symmetrical/asymmetrical deposit type, i.e they can choose any of the three - // If null, user can only deposit symmetrical - // If AsymSide, user can only deposit asymmetrical on said Asymside - asymSide?: AsymSide | null + onAsymSideChange: (asymSide: string | null) => void + defaultOpportunityId?: string } -export const DepositType = ({ assetId, asymSide }: DepositTypeProps) => { +export const DepositType = ({ + assetId, + defaultOpportunityId, + onAsymSideChange, +}: DepositTypeProps) => { const assetIds = useMemo(() => { return [assetId, thorchainAssetId] }, [assetId]) @@ -99,12 +101,11 @@ export const DepositType = ({ assetId, asymSide }: DepositTypeProps) => { const { getRootProps, getRadioProps } = useRadioGroup({ name: 'depositType', defaultValue: 'one', - onChange: console.log, + onChange: onAsymSideChange, }) const radioOptions = useMemo(() => { - const _options = asymSide ? [{ value: asymSide }] : options - if (_options.length === 1) return null + const _options = defaultOpportunityId ? options : [] return _options.map((option, index) => { const radio = getRadioProps({ value: option.value }) @@ -124,7 +125,7 @@ export const DepositType = ({ assetId, asymSide }: DepositTypeProps) => { ) }) - }, [asymSide, getRadioProps, makeAssetIdsOption]) + }, [defaultOpportunityId, getRadioProps, makeAssetIdsOption]) const group = getRootProps() return (