From ac7d4546cf3658c7642094ef42b27ab7a89812b0 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 11 Sep 2024 00:20:48 +0200 Subject: [PATCH 1/2] feat: augment runtime LP tokens metadata from Portals (#7675) --- .../utils/src/createThrottle.ts | 0 packages/utils/src/index.ts | 1 + src/lib/market-service/portals/portals.ts | 2 +- src/lib/market-service/portals/types.ts | 5 + src/lib/market-service/zerion/zerion.ts | 2 +- src/lib/portals/utils.ts | 83 ++++++++++++++ src/state/migrations/index.ts | 1 + .../slices/portfolioSlice/portfolioSlice.ts | 106 +++++++++++++++++- .../{utils.test.ts => utils/index.test.ts} | 2 +- .../{utils.ts => utils/index.ts} | 4 +- 10 files changed, 198 insertions(+), 8 deletions(-) rename src/lib/market-service/utils.ts => packages/utils/src/createThrottle.ts (100%) create mode 100644 src/lib/portals/utils.ts rename src/state/slices/portfolioSlice/{utils.test.ts => utils/index.test.ts} (98%) rename src/state/slices/portfolioSlice/{utils.ts => utils/index.ts} (99%) diff --git a/src/lib/market-service/utils.ts b/packages/utils/src/createThrottle.ts similarity index 100% rename from src/lib/market-service/utils.ts rename to packages/utils/src/createThrottle.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8f20df69e14..08dc654bf5e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,6 +12,7 @@ export * from './baseUnits/baseUnits' export * from './promises' export * from './treasury' export * from './timeout' +export * from './createThrottle' export const isSome = (option: T | null | undefined): option is T => !isUndefined(option) && !isNull(option) diff --git a/src/lib/market-service/portals/portals.ts b/src/lib/market-service/portals/portals.ts index 57d938c0135..3bff0762fff 100644 --- a/src/lib/market-service/portals/portals.ts +++ b/src/lib/market-service/portals/portals.ts @@ -10,6 +10,7 @@ import type { PriceHistoryArgs, } from '@shapeshiftoss/types' import { HistoryTimeframe } from '@shapeshiftoss/types' +import { createThrottle } from '@shapeshiftoss/utils' import Axios from 'axios' import { setupCache } from 'axios-cache-interceptor' import { getConfig } from 'config' @@ -22,7 +23,6 @@ import { assertUnreachable, getTimeFrameBounds, isToken } from 'lib/utils' import generatedAssetData from '../../asset-service/service/generatedAssetData.json' import type { MarketService } from '../api' import { DEFAULT_CACHE_TTL_MS } from '../config' -import { createThrottle } from '../utils' import { isValidDate } from '../utils/isValidDate' import { CHAIN_ID_TO_PORTALS_NETWORK } from './constants' import type { GetTokensResponse, HistoryResponse } from './types' diff --git a/src/lib/market-service/portals/types.ts b/src/lib/market-service/portals/types.ts index 240c385eecf..8914de3ebda 100644 --- a/src/lib/market-service/portals/types.ts +++ b/src/lib/market-service/portals/types.ts @@ -29,6 +29,11 @@ export type PlatformsById = Record export type GetPlatformsResponse = Platform[] +export type GetBalancesResponse = { + // Not strictly true, this has additional fields, but we're only interested in the token info part + balances: TokenInfo[] +} + export type GetTokensResponse = { totalItems: number pageItems: number diff --git a/src/lib/market-service/zerion/zerion.ts b/src/lib/market-service/zerion/zerion.ts index 82ff1ce3fd8..e35198bdc1b 100644 --- a/src/lib/market-service/zerion/zerion.ts +++ b/src/lib/market-service/zerion/zerion.ts @@ -16,6 +16,7 @@ import { type PriceHistoryArgs, ZERION_CHAINS_MAP, } from '@shapeshiftoss/types' +import { createThrottle } from '@shapeshiftoss/utils' import Axios from 'axios' import { setupCache } from 'axios-cache-interceptor' import { getConfig } from 'config' @@ -25,7 +26,6 @@ import { assertUnreachable, isToken } from 'lib/utils' import type { MarketService } from '../api' import { DEFAULT_CACHE_TTL_MS } from '../config' -import { createThrottle } from '../utils' import type { ListFungiblesResponse, ZerionChartResponse, diff --git a/src/lib/portals/utils.ts b/src/lib/portals/utils.ts new file mode 100644 index 00000000000..f7c8cbaadd8 --- /dev/null +++ b/src/lib/portals/utils.ts @@ -0,0 +1,83 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { ASSET_NAMESPACE, bscChainId, toAssetId } from '@shapeshiftoss/caip' +import axios from 'axios' +import { getConfig } from 'config' +import { CHAIN_ID_TO_PORTALS_NETWORK } from 'lib/market-service/portals/constants' +import type { + GetBalancesResponse, + GetPlatformsResponse, + PlatformsById, + TokenInfo, +} from 'lib/market-service/portals/types' + +export const fetchPortalsPlatforms = async (): Promise => { + const url = `${getConfig().REACT_APP_PORTALS_BASE_URL}/v2/platforms` + + try { + const { data: platforms } = await axios.get(url, { + headers: { + Authorization: `Bearer ${getConfig().REACT_APP_PORTALS_API_KEY}`, + }, + }) + + const byId = platforms.reduce((acc, platform) => { + acc[platform.platform] = platform + return acc + }, {}) + + return byId + } catch (error) { + if (axios.isAxiosError(error)) { + console.error(`Failed to fetch Portals platforms: ${error.message}`) + } + console.error(`Failed to fetch Portals platforms: ${error}`) + + return {} + } +} + +export const fetchPortalsAccount = async ( + chainId: ChainId, + owner: string, +): Promise> => { + const url = `${getConfig().REACT_APP_PORTALS_BASE_URL}/v2/account` + + const network = CHAIN_ID_TO_PORTALS_NETWORK[chainId] + + if (!network) throw new Error(`Unsupported chainId: ${chainId}`) + + try { + const { data } = await axios.get(url, { + params: { + networks: [network], + owner, + }, + headers: { + Authorization: `Bearer ${getConfig().REACT_APP_PORTALS_API_KEY}`, + }, + }) + + return data.balances.reduce>((acc, token) => { + const assetId = toAssetId({ + chainId, + assetNamespace: chainId === bscChainId ? ASSET_NAMESPACE.bep20 : ASSET_NAMESPACE.erc20, + assetReference: token.address, + }) + acc[assetId] = token + return acc + }, {}) + } catch (error) { + if (axios.isAxiosError(error)) { + console.error(`Failed to fetch Portals account: ${error.message}`) + } else { + console.error(error) + } + return {} + } +} + +export const maybeTokenImage = (image: string | undefined) => { + if (!image) return + if (image === 'missing_large.png') return + return image +} diff --git a/src/state/migrations/index.ts b/src/state/migrations/index.ts index 5a650962683..f2e7fa4d6cd 100644 --- a/src/state/migrations/index.ts +++ b/src/state/migrations/index.ts @@ -115,4 +115,5 @@ export const migrations = { 104: clearTxHistory, 105: clearPortfolio, 106: clearOpportunities, + 107: clearAssets, } diff --git a/src/state/slices/portfolioSlice/portfolioSlice.ts b/src/state/slices/portfolioSlice/portfolioSlice.ts index 4ade02ed43d..512b667413a 100644 --- a/src/state/slices/portfolioSlice/portfolioSlice.ts +++ b/src/state/slices/portfolioSlice/portfolioSlice.ts @@ -1,10 +1,11 @@ import { createSlice, prepareAutoBatched } from '@reduxjs/toolkit' import { createApi } from '@reduxjs/toolkit/query/react' import type { AccountId, ChainId } from '@shapeshiftoss/caip' -import { fromAccountId, isNft } from '@shapeshiftoss/caip' +import { ASSET_NAMESPACE, bscChainId, fromAccountId, isNft, toAssetId } from '@shapeshiftoss/caip' import type { Account } from '@shapeshiftoss/chain-adapters' import { evmChainIds } from '@shapeshiftoss/chain-adapters' import type { AccountMetadataById, EvmChainId } from '@shapeshiftoss/types' +import type { MinimalAsset } from '@shapeshiftoss/utils' import { makeAsset } from '@shapeshiftoss/utils' import cloneDeep from 'lodash/cloneDeep' import merge from 'lodash/merge' @@ -13,6 +14,7 @@ import { PURGE } from 'redux-persist' import { getChainAdapterManager } from 'context/PluginProvider/chainAdapterSingleton' import { getMixPanel } from 'lib/mixpanel/mixPanelSingleton' import { MixPanelEvent } from 'lib/mixpanel/types' +import { fetchPortalsAccount, fetchPortalsPlatforms, maybeTokenImage } from 'lib/portals/utils' import { BASE_RTK_CREATE_API_CONFIG } from 'state/apis/const' import { isSpammyNftText, isSpammyTokenText } from 'state/apis/nft/constants' import { selectNftCollections } from 'state/apis/nft/selectors' @@ -197,9 +199,11 @@ export const portfolioApi = createApi({ const portfolioAccounts = { [pubkey]: await adapter.getAccount(pubkey) } const nftCollectionsById = selectNftCollections(state) - const data = ((): Portfolio => { + const data = await (async (): Promise => { // add placeholder non spam assets for evm chains if (evmChainIds.includes(chainId as EvmChainId)) { + const maybePortalsAccounts = await fetchPortalsAccount(chainId, pubkey) + const maybePortalsPlatforms = await fetchPortalsPlatforms() const account = portfolioAccounts[pubkey] as Account const assets = (account.chainSpecific.tokens ?? []).reduce( @@ -209,7 +213,103 @@ export const portfolioApi = createApi({ return isSpammyTokenText(text) }) if (state.assets.byId[token.assetId] || isSpam) return prev - prev.byId[token.assetId] = makeAsset(state.assets.byId, { ...token }) + let minimalAsset: MinimalAsset = token + const maybePortalsAsset = maybePortalsAccounts[token.assetId] + if (maybePortalsAsset) { + const isPool = Boolean( + maybePortalsAsset.platform && maybePortalsAsset.tokens?.length, + ) + const platform = maybePortalsPlatforms[maybePortalsAsset.platform] + + const name = (() => { + // For single assets, just use the token name + if (!isPool) return maybePortalsAsset.name + // For pools, create a name in the format of " Pool" + // e.g "UniswapV2 ETH/FOX Pool" + const assetSymbols = + maybePortalsAsset.tokens?.map(underlyingToken => { + const assetId = toAssetId({ + chainId, + assetNamespace: + chainId === bscChainId + ? ASSET_NAMESPACE.bep20 + : ASSET_NAMESPACE.erc20, + assetReference: underlyingToken, + }) + const underlyingAsset = state.assets.byId[assetId] + if (!underlyingAsset) return undefined + + // This doesn't generalize, but this'll do, this is only a visual hack to display native asset instead of wrapped + // We could potentially use related assets for this and use primary implementation, though we'd have to remove BTC from there as WBTC and BTC are very + // much different assets on diff networks, i.e can't deposit BTC instead of WBTC automagically like you would with ETH instead of WETH + switch (underlyingAsset.symbol) { + case 'WETH': + return 'ETH' + case 'WBNB': + return 'BNB' + case 'WMATIC': + return 'MATIC' + case 'WAVAX': + return 'AVAX' + default: + return underlyingAsset.symbol + } + }) ?? [] + + // Our best effort to contruct sane name using the native asset -> asset naming hack failed, but thankfully, upstream name is very close e.g + // for "UniswapV2 LP TRUST/WETH", we just have to append "Pool" to that and we're gucci + if (assetSymbols.some(symbol => !symbol)) return `${token.name} Pool` + return `${platform.name} ${assetSymbols.join('/')} Pool` + })() + + const images = maybePortalsAsset.images ?? [] + const [, ...underlyingAssetsImages] = images + const iconOrIcons = (() => { + // There are no underlying tokens' images, return asset icon if it exists + if (!underlyingAssetsImages?.length) + return { icon: state.assets.byId[token.assetId]?.icon } + + if (underlyingAssetsImages.length === 1) { + return { + icon: maybeTokenImage( + maybePortalsAsset.image || underlyingAssetsImages[0], + ), + } + } + // This is a multiple assets pool, populate icons array + if (underlyingAssetsImages.length > 1) + return { + icons: underlyingAssetsImages.map((underlyingAssetsImage, i) => { + // No token at that index, but this isn't reliable as we've found out, it may be missing in tokens but present in images + // However, this has to be an early return and we can't use our own flavour of that asset... because we have no idea which asset it is. + if (!maybePortalsAsset.tokens[i]) + return maybeTokenImage(underlyingAssetsImage) + + const underlyingAssetId = toAssetId({ + chainId, + assetNamespace: + chainId === bscChainId + ? ASSET_NAMESPACE.bep20 + : ASSET_NAMESPACE.erc20, + assetReference: maybePortalsAsset.tokens[i], + }) + const underlyingAsset = state.assets.byId[underlyingAssetId] + // Prioritise our own flavour of icons for that asset if available, else use upstream if present + return underlyingAsset?.icon || maybeTokenImage(underlyingAssetsImage) + }), + icon: undefined, + } + })() + + // @ts-ignore this is the best we can do, some icons *may* be missing + minimalAsset = { + ...minimalAsset, + name, + isPool, + ...iconOrIcons, + } + } + prev.byId[token.assetId] = makeAsset(state.assets.byId, minimalAsset) prev.ids.push(token.assetId) return prev }, diff --git a/src/state/slices/portfolioSlice/utils.test.ts b/src/state/slices/portfolioSlice/utils/index.test.ts similarity index 98% rename from src/state/slices/portfolioSlice/utils.test.ts rename to src/state/slices/portfolioSlice/utils/index.test.ts index b77f6d90e8d..1f654ddb9ac 100644 --- a/src/state/slices/portfolioSlice/utils.test.ts +++ b/src/state/slices/portfolioSlice/utils/index.test.ts @@ -13,7 +13,7 @@ import { describe, expect, it, vi } from 'vitest' import { trimWithEndEllipsis } from 'lib/utils' import { accountIdToFeeAssetId } from 'lib/utils/accounts' -import { accountIdToLabel, findAccountsByAssetId } from './utils' +import { accountIdToLabel, findAccountsByAssetId } from '.' vi.mock('context/PluginProvider/chainAdapterSingleton', () => ({ getChainAdapterManager: () => mockChainAdapters, diff --git a/src/state/slices/portfolioSlice/utils.ts b/src/state/slices/portfolioSlice/utils/index.ts similarity index 99% rename from src/state/slices/portfolioSlice/utils.ts rename to src/state/slices/portfolioSlice/utils/index.ts index f8cd68cc689..46acaaa1fd7 100644 --- a/src/state/slices/portfolioSlice/utils.ts +++ b/src/state/slices/portfolioSlice/utils/index.ts @@ -53,8 +53,8 @@ import type { Portfolio, PortfolioAccountBalancesById, PortfolioAccounts as PortfolioSliceAccounts, -} from './portfolioSliceCommon' -import { initialState } from './portfolioSliceCommon' +} from '../portfolioSliceCommon' +import { initialState } from '../portfolioSliceCommon' // note - this isn't a selector, just a pure utility function export const accountIdToLabel = (accountId: AccountId): string => { From 42213f534477fdbe58068bd8de3942cf50445269 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 11 Sep 2024 03:26:55 +0200 Subject: [PATCH 2/2] feat: snap migration cta (#7728) * feat: unrug RUNE in MM snaps * feat: prepare for publish * chore: trigger CI * feat: install * feat: unrug RUNE in MM snaps * feat: prepare for publish * chore: trigger CI * feat: shapeshift snap migration cta * feat: progression * feat: revert version checks * fix: close * feat: cleanup * fix: remove * feat: snap uninstalled state * fix: more copies * feat: the last cleanup * fix: layout shift * feat: intro btn copy * feat: cleanup some more * gomes-passed-out-with-a-dog-on-his-face * Update src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx * Update src/assets/translations/en/main.json * Update src/assets/translations/en/main.json --------- Co-authored-by: Tim Black Co-authored-by: Apotheosis <0xapotheosis@gmail.com> --- .env.base | 1 + src/assets/translations/en/main.json | 7 +++- src/components/Layout/Header/Header.tsx | 18 +++++++-- .../Layout/Header/NavBar/Notifications.tsx | 2 +- src/components/Modals/Snaps/SnapContent.tsx | 10 ++++- src/components/Modals/Snaps/SnapIntro.tsx | 39 +++++++++++++++---- src/components/Modals/Snaps/Snaps.tsx | 15 +++++-- .../components/ManualAddressEntry.tsx | 3 +- .../hooks/useSupportedAssets.tsx | 2 +- .../StakingVaults/PositionTable.tsx | 2 +- src/components/StakingVaults/ProviderCard.tsx | 2 +- src/config.ts | 1 + .../MetaMask/components/Connect.tsx | 9 +++++ .../MetaMask/components/MetaMaskMenu.tsx | 24 +++++++----- .../MetaMask/components/SnapInstall.tsx | 9 ++++- .../MetaMask/components/SnapUpdate.tsx | 17 ++++++++ src/context/WalletProvider/config.ts | 6 +++ .../useIsSnapInstalled/useIsSnapInstalled.tsx | 13 ++++++- .../useWalletSupportsChain.ts | 2 +- .../hooks/useLendingSupportedAssets/index.ts | 2 +- .../AddLiquidity/AddLiquidityInput.tsx | 2 +- .../RemoveLiquidity/RemoveLiquidityInput.tsx | 2 +- src/utils/snaps.ts | 31 ++++++++++++++- 23 files changed, 178 insertions(+), 41 deletions(-) create mode 100644 src/context/WalletProvider/MetaMask/components/SnapUpdate.tsx diff --git a/.env.base b/.env.base index a9aa6e69ad9..3e46abc63ef 100644 --- a/.env.base +++ b/.env.base @@ -163,6 +163,7 @@ REACT_APP_PORTALS_API_KEY=bbc3ba7e-5f2a-4a0a-bbbc-22509944686c REACT_APP_ONE_INCH_API_URL=https://api-shapeshift.1inch.io/v5.0 REACT_APP_SNAP_ID=npm:@shapeshiftoss/metamask-snaps +REACT_APP_SNAP_VERSION=1.0.9 # REACT_APP_SNAP_ID=local:http://localhost:9000 REACT_APP_EXPERIMENTAL_MM_SNAPPY_FINGERS=true diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index d0502f74c47..918afebd63d 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -171,7 +171,8 @@ }, "featureDisabled": "This feature is temporarily disabled.", "yes": "Yes", - "activeAccount": "Active Account" + "activeAccount": "Active Account", + "update": "Update" }, "consentBanner": { "body": { @@ -1535,6 +1536,10 @@ "title": "You have removed Multichain support from MetaMask!", "subtitle": "Re-add the Multichain Snap on MetaMask to send, receive, track, trade, and earn with the following chains:" }, + "update": { + "title": "Multichain Snap needs updating", + "subtitle": "Uninstall the ShapeShift multichain Snap and click 'Update' to keep using ShapeShifts multichain features with MetaMask!" + }, "secondaryTitle": "The best Multichain experience for MetaMask: Powered by ShapeShift", "secondaryBody": "Send, receive, track, trade, and earn with the ShapeShift Multichain Snap on the following chains:", "connectMetaMask": "Connect MetaMask", diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx index 89987dfa75c..2092fb3e619 100644 --- a/src/components/Layout/Header/Header.tsx +++ b/src/components/Layout/Header/Header.tsx @@ -50,8 +50,9 @@ const widthProp = { base: 'auto', md: 'full' } export const Header = memo(() => { const isDegradedState = useSelector(selectPortfolioDegradedState) const snapModal = useModal('snaps') - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled, isCorrectVersion } = useIsSnapInstalled() const previousSnapInstall = usePrevious(isSnapInstalled) + const previousIsCorrectVersion = usePrevious(isCorrectVersion) const showSnapModal = useSelector(selectShowSnapsModal) const [isLargerThanMd] = useMediaQuery(`(min-width: ${breakpoints['md']})`) @@ -113,7 +114,14 @@ export const Header = memo(() => { }, [appDispatch, currentWalletId, hasUtxoAccountIds, isSnapInstalled, wallet, walletAccountIds]) useEffect(() => { - if (previousSnapInstall === true && isSnapInstalled === false) { + if (!isCorrectVersion && isSnapInstalled) return + if (snapModal.isOpen) return + + if ( + previousSnapInstall === true && + isSnapInstalled === false && + previousIsCorrectVersion === true + ) { // they uninstalled the snap toast({ status: 'success', @@ -123,7 +131,7 @@ export const Header = memo(() => { const walletId = currentWalletId if (!walletId) return appDispatch(portfolio.actions.clearWalletMetadata(walletId)) - snapModal.open({ isRemoved: true }) + return snapModal.open({ isRemoved: true }) } if (previousSnapInstall === false && isSnapInstalled === true) { history.push(`/assets/${btcAssetId}`) @@ -134,14 +142,16 @@ export const Header = memo(() => { title: translate('walletProvider.metaMaskSnap.snapInstalledToast'), position: 'bottom', }) - dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: false }) + return dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: false }) } }, [ appDispatch, currentWalletId, dispatch, history, + isCorrectVersion, isSnapInstalled, + previousIsCorrectVersion, previousSnapInstall, showSnapModal, snapModal, diff --git a/src/components/Layout/Header/NavBar/Notifications.tsx b/src/components/Layout/Header/NavBar/Notifications.tsx index 589c0b8096e..27f1fb2393e 100644 --- a/src/components/Layout/Header/NavBar/Notifications.tsx +++ b/src/components/Layout/Header/NavBar/Notifications.tsx @@ -44,7 +44,7 @@ export const Notifications = memo(() => { const { state: { wallet, modalType }, } = useWallet() - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const ethAccountIds = useAppSelector(state => selectAccountIdsByAssetId(state, { assetId: ethAssetId }), diff --git a/src/components/Modals/Snaps/SnapContent.tsx b/src/components/Modals/Snaps/SnapContent.tsx index dbc4813a3fe..7646f82b4a5 100644 --- a/src/components/Modals/Snaps/SnapContent.tsx +++ b/src/components/Modals/Snaps/SnapContent.tsx @@ -5,9 +5,13 @@ import { SnapIntro } from './SnapIntro' export const SnapContent = ({ isRemoved, + isCorrectVersion, + isSnapInstalled, onClose, }: { isRemoved?: boolean + isCorrectVersion: boolean + isSnapInstalled: boolean onClose: () => void }) => { return ( @@ -16,7 +20,11 @@ export const SnapContent = ({ {({ location }) => ( - + diff --git a/src/components/Modals/Snaps/SnapIntro.tsx b/src/components/Modals/Snaps/SnapIntro.tsx index 96bfb368457..fc31029f279 100644 --- a/src/components/Modals/Snaps/SnapIntro.tsx +++ b/src/components/Modals/Snaps/SnapIntro.tsx @@ -10,6 +10,7 @@ import { ModalFooter, ModalHeader, Text, + usePrevious, } from '@chakra-ui/react' import { knownChainIds } from 'constants/chains' import { useCallback, useMemo } from 'react' @@ -27,17 +28,32 @@ import { preferences } from 'state/slices/preferencesSlice/preferencesSlice' import { selectAssetById } from 'state/slices/selectors' import { store } from 'state/store' -export const SnapIntro = ({ isRemoved }: { isRemoved?: boolean }) => { +export const SnapIntro = ({ + isRemoved, + isCorrectVersion, + isSnapInstalled, +}: { + isRemoved?: boolean + isCorrectVersion: boolean + isSnapInstalled: boolean +}) => { const translate = useTranslate() const history = useHistory() + const previousIsCorrectVersion = usePrevious(isCorrectVersion) - const titleSlug = isRemoved - ? 'walletProvider.metaMaskSnap.uninstall.title' - : 'walletProvider.metaMaskSnap.title' + const titleSlug = useMemo(() => { + if (isRemoved) return 'walletProvider.metaMaskSnap.uninstall.title' + if ((!isCorrectVersion && isSnapInstalled) || previousIsCorrectVersion === false) + return 'walletProvider.metaMaskSnap.update.title' + return 'walletProvider.metaMaskSnap.title' + }, [isCorrectVersion, isRemoved, isSnapInstalled, previousIsCorrectVersion]) - const bodySlug = isRemoved - ? 'walletProvider.metaMaskSnap.uninstall.subtitle' - : 'walletProvider.metaMaskSnap.subtitle' + const bodySlug = useMemo(() => { + if (isRemoved) return 'walletProvider.metaMaskSnap.uninstall.subtitle' + if ((!isCorrectVersion && isSnapInstalled) || previousIsCorrectVersion === false) + return 'walletProvider.metaMaskSnap.update.subtitle' + return 'walletProvider.metaMaskSnap.subtitle' + }, [isCorrectVersion, isRemoved, isSnapInstalled, previousIsCorrectVersion]) const allNativeAssets = useMemo(() => { return knownChainIds @@ -68,6 +84,13 @@ export const SnapIntro = ({ isRemoved }: { isRemoved?: boolean }) => { history.push('/confirm') }, [history]) + const confirmCopy = useMemo(() => { + if ((!isCorrectVersion && isSnapInstalled) || previousIsCorrectVersion === false) + return translate('common.update') + + if (!isSnapInstalled || isRemoved) return translate('walletProvider.metaMaskSnap.addSnap') + }, [isCorrectVersion, isRemoved, isSnapInstalled, previousIsCorrectVersion, translate]) + return ( <> @@ -113,7 +136,7 @@ export const SnapIntro = ({ isRemoved }: { isRemoved?: boolean }) => { diff --git a/src/components/Modals/Snaps/Snaps.tsx b/src/components/Modals/Snaps/Snaps.tsx index bebdc4f2b1f..6b456db35b8 100644 --- a/src/components/Modals/Snaps/Snaps.tsx +++ b/src/components/Modals/Snaps/Snaps.tsx @@ -13,26 +13,33 @@ export type SnapsModalProps = { export const Snaps: React.FC = ({ isRemoved }) => { const { close, isOpen } = useModal('snaps') const isSnapsEnabled = useFeatureFlag('Snaps') - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled, isCorrectVersion } = useIsSnapInstalled() useEffect(() => { - if (isSnapInstalled) { + if (isSnapInstalled && isCorrectVersion) { close() } - }, [close, isSnapInstalled]) + }, [close, isCorrectVersion, isSnapInstalled]) const handleClose = useCallback(() => { close() }, [close]) if (!isSnapsEnabled) return null + if (isSnapInstalled === null) return null + if (isCorrectVersion === null) return null return ( - + ) diff --git a/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx b/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx index 94487897882..32188bdb4fe 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx @@ -43,7 +43,7 @@ export const ManualAddressEntry: FC = memo( const { chainId: buyAssetChainId, assetId: buyAssetAssetId } = useAppSelector(selectInputBuyAsset) - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const walletSupportsBuyAssetChain = useWalletSupportsChain(buyAssetChainId, wallet) const buyAssetAccountIds = useAppSelector(state => selectAccountIdsByAssetId(state, { assetId: buyAssetAssetId }), @@ -125,6 +125,7 @@ export const ManualAddressEntry: FC = memo( [buyAssetAssetId, buyAssetChainId, dispatch], ) + // We're enabling the snap, so no versioning concerns here const handleEnableShapeShiftSnap = useCallback(() => openSnapsModal({}), [openSnapsModal]) const handleAddAccount = useCallback( () => openManageAccountsModal({}), diff --git a/src/components/MultiHopTrade/hooks/useSupportedAssets.tsx b/src/components/MultiHopTrade/hooks/useSupportedAssets.tsx index d92f78b250e..ac3ade39243 100644 --- a/src/components/MultiHopTrade/hooks/useSupportedAssets.tsx +++ b/src/components/MultiHopTrade/hooks/useSupportedAssets.tsx @@ -13,7 +13,7 @@ export const useSupportedAssets = () => { const sortedAssets = useAppSelector(selectAssetsSortedByMarketCapUserCurrencyBalanceAndName) const assets = useAppSelector(selectAssets) const wallet = useWallet().state.wallet - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const accountIdsByChainId = useAppSelector(selectAccountIdsByChainId) const queryParams = useMemo(() => { diff --git a/src/components/StakingVaults/PositionTable.tsx b/src/components/StakingVaults/PositionTable.tsx index 23722bee8b1..6bf5deee505 100644 --- a/src/components/StakingVaults/PositionTable.tsx +++ b/src/components/StakingVaults/PositionTable.tsx @@ -94,7 +94,7 @@ export const PositionTable: React.FC = ({ ), ) - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const accountIdsByChainId = useAppSelector(selectAccountIdsByChainId) const filteredPositions = useMemo( diff --git a/src/components/StakingVaults/ProviderCard.tsx b/src/components/StakingVaults/ProviderCard.tsx index a38d23a5451..200467d111f 100644 --- a/src/components/StakingVaults/ProviderCard.tsx +++ b/src/components/StakingVaults/ProviderCard.tsx @@ -66,7 +66,7 @@ export const ProviderCard: React.FC = ({ selectAggregatedEarnUserStakingOpportunitiesIncludeEmpty, ) - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const filteredDownStakingOpportunities = useMemo( () => diff --git a/src/config.ts b/src/config.ts index e51361147f7..82d39f7d892 100644 --- a/src/config.ts +++ b/src/config.ts @@ -154,6 +154,7 @@ const validators = { REACT_APP_EXPERIMENTAL_CUSTOM_SEND_NONCE: bool({ default: false }), REACT_APP_EXPERIMENTAL_MM_SNAPPY_FINGERS: bool({ default: false }), REACT_APP_SNAP_ID: str(), + REACT_APP_SNAP_VERSION: str(), REACT_APP_FEATURE_THORCHAIN_LENDING: bool({ default: false }), REACT_APP_FEATURE_THORCHAIN_LENDING_BORROW: bool({ default: false }), REACT_APP_FEATURE_THORCHAIN_LENDING_REPAY: bool({ default: false }), diff --git a/src/context/WalletProvider/MetaMask/components/Connect.tsx b/src/context/WalletProvider/MetaMask/components/Connect.tsx index 5d4a324181e..b98d931689c 100644 --- a/src/context/WalletProvider/MetaMask/components/Connect.tsx +++ b/src/context/WalletProvider/MetaMask/components/Connect.tsx @@ -1,7 +1,9 @@ +import { getConfig } from 'config' import React, { useCallback, useState } from 'react' import { isMobile } from 'react-device-detect' import { useSelector } from 'react-redux' import type { RouteComponentProps } from 'react-router-dom' +import { getSnapVersion } from 'utils/snaps' import type { ActionTypes } from 'context/WalletProvider/actions' import { WalletActions } from 'context/WalletProvider/actions' import { KeyManager } from 'context/WalletProvider/KeyManager' @@ -90,6 +92,13 @@ export const MetaMaskConnect = ({ history }: MetaMaskSetupProps) => { return dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: false }) const isSnapInstalled = await checkIsSnapInstalled() + const snapVersion = await getSnapVersion() + + const isCorrectVersion = snapVersion === getConfig().REACT_APP_SNAP_VERSION + + if (isSnapsEnabled && isSnapInstalled && !isCorrectVersion && showSnapModal) { + return history.push('/metamask/snap/update') + } if (isSnapsEnabled && !isSnapInstalled && showSnapModal) { return history.push('/metamask/snap/install') } diff --git a/src/context/WalletProvider/MetaMask/components/MetaMaskMenu.tsx b/src/context/WalletProvider/MetaMask/components/MetaMaskMenu.tsx index 63d87cfda86..638824b3ac2 100644 --- a/src/context/WalletProvider/MetaMask/components/MetaMaskMenu.tsx +++ b/src/context/WalletProvider/MetaMask/components/MetaMaskMenu.tsx @@ -15,7 +15,7 @@ type MetaMaskMenuProps = { } export const MetaMaskMenu: React.FC = ({ onClose }) => { - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled, isCorrectVersion } = useIsSnapInstalled() const translate = useTranslate() const snapModal = useModal('snaps') const [isMetaMask, setIsMetaMask] = useState(null) @@ -34,27 +34,33 @@ export const MetaMaskMenu: React.FC = ({ onClose }) => { }, [wallet]) const handleClick = useCallback(() => { - if (isSnapInstalled === false) { + if (isSnapInstalled === false || isCorrectVersion === false) { snapModal.open({}) } - }, [isSnapInstalled, snapModal]) + }, [isCorrectVersion, isSnapInstalled, snapModal]) const renderSnapStatus = useMemo(() => { - if (isSnapInstalled === true) { - return {translate('walletProvider.metaMaskSnap.active')} - } else { + if (isSnapInstalled) { + return isCorrectVersion ? ( + {translate('walletProvider.metaMaskSnap.active')} + ) : ( + {translate('common.update')} + ) + } + + if (!isSnapInstalled) { return {translate('walletProvider.metaMaskSnap.notActive')} } - }, [isSnapInstalled, translate]) + }, [isCorrectVersion, isSnapInstalled, translate]) return isMetaMask ? ( <> - {isSnapInstalled && } + {isSnapInstalled && isCorrectVersion && } {translate('walletProvider.metaMaskSnap.multiChainSnap')} {renderSnapStatus} diff --git a/src/context/WalletProvider/MetaMask/components/SnapInstall.tsx b/src/context/WalletProvider/MetaMask/components/SnapInstall.tsx index b051eea1a86..be3f4769e8f 100644 --- a/src/context/WalletProvider/MetaMask/components/SnapInstall.tsx +++ b/src/context/WalletProvider/MetaMask/components/SnapInstall.tsx @@ -10,5 +10,12 @@ export const SnapInstall = () => { dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: false }) }, [dispatch]) - return + return ( + + ) } diff --git a/src/context/WalletProvider/MetaMask/components/SnapUpdate.tsx b/src/context/WalletProvider/MetaMask/components/SnapUpdate.tsx new file mode 100644 index 00000000000..fc9f9b4a82b --- /dev/null +++ b/src/context/WalletProvider/MetaMask/components/SnapUpdate.tsx @@ -0,0 +1,17 @@ +import { useCallback } from 'react' +import { SnapContent } from 'components/Modals/Snaps/SnapContent' +import { WalletActions } from 'context/WalletProvider/actions' +import { useWallet } from 'hooks/useWallet/useWallet' + +export const SnapUpdate = () => { + const { dispatch } = useWallet() + + const handleClose = useCallback(() => { + dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: false }) + }, [dispatch]) + + return ( + // If we land here, we know the version is incorrect + + ) +} diff --git a/src/context/WalletProvider/config.ts b/src/context/WalletProvider/config.ts index 0952ea1549d..bb6f197f941 100644 --- a/src/context/WalletProvider/config.ts +++ b/src/context/WalletProvider/config.ts @@ -102,6 +102,11 @@ const EnterPassword = lazy(() => const SnapInstall = lazy(() => import('./MetaMask/components/SnapInstall').then(({ SnapInstall }) => ({ default: SnapInstall })), ) + +const SnapUpdate = lazy(() => + import('./MetaMask/components/SnapUpdate').then(({ SnapUpdate }) => ({ default: SnapUpdate })), +) + const ChangeLabel = lazy(() => import('components/Layout/Header/NavBar/KeepKey/ChangeLabel').then(({ ChangeLabel }) => ({ default: ChangeLabel, @@ -394,6 +399,7 @@ export const SUPPORTED_WALLETS: SupportedWalletInfoByKeyManager = { routes: [ { path: '/metamask/connect', component: MetaMaskConnect }, { path: '/metamask/snap/install', component: SnapInstall }, + { path: '/metamask/snap/update', component: SnapUpdate }, { path: '/metamask/failure', component: MetaMaskFailure }, ], connectedMenuComponent: MetaMaskMenu, diff --git a/src/hooks/useIsSnapInstalled/useIsSnapInstalled.tsx b/src/hooks/useIsSnapInstalled/useIsSnapInstalled.tsx index 37c9b0dd22d..5baf2b8a43c 100644 --- a/src/hooks/useIsSnapInstalled/useIsSnapInstalled.tsx +++ b/src/hooks/useIsSnapInstalled/useIsSnapInstalled.tsx @@ -10,10 +10,12 @@ import type { Eip1193Provider } from 'ethers' import pDebounce from 'p-debounce' import pMemoize from 'p-memoize' import { useCallback, useEffect, useState } from 'react' +import { getSnapVersion } from 'utils/snaps' import { useWallet } from 'hooks/useWallet/useWallet' const POLL_INTERVAL = 3000 // tune me to make this "feel" right const snapId = getConfig().REACT_APP_SNAP_ID +const snapVersion = getConfig().REACT_APP_SNAP_VERSION // Many many user-agents to detect mobile MM and other in-app dApp browsers // https://github.com/MetaMask/metamask-mobile/issues/3920#issuecomment-1074188335 @@ -128,8 +130,12 @@ export const checkIsMetaMaskImpersonator = pMemoize( }, ) -export const useIsSnapInstalled = (): null | boolean => { +export const useIsSnapInstalled = (): { + isSnapInstalled: boolean | null + isCorrectVersion: boolean | null +} => { const [isSnapInstalled, setIsSnapInstalled] = useState(null) + const [isCorrectVersion, setIsCorrectVersion] = useState(null) const { state: { wallet, isConnected, isDemoWallet }, @@ -142,7 +148,10 @@ export const useIsSnapInstalled = (): null | boolean => { if (isMetaMaskImpersonator) return if (!isMetaMaskDesktop) return + const version = await getSnapVersion() const _isSnapInstalled = await checkIsSnapInstalled() + + setIsCorrectVersion(version === snapVersion) setIsSnapInstalled(_isSnapInstalled) }, [isConnected, isDemoWallet, wallet]) @@ -157,7 +166,7 @@ export const useIsSnapInstalled = (): null | boolean => { return () => clearInterval(intervalId) }, [checkSnapInstallation, wallet]) - return isSnapInstalled + return { isSnapInstalled, isCorrectVersion } } export const canAddMetaMaskAccount = ({ diff --git a/src/hooks/useWalletSupportsChain/useWalletSupportsChain.ts b/src/hooks/useWalletSupportsChain/useWalletSupportsChain.ts index 4488948a50f..60b8c59724c 100644 --- a/src/hooks/useWalletSupportsChain/useWalletSupportsChain.ts +++ b/src/hooks/useWalletSupportsChain/useWalletSupportsChain.ts @@ -119,7 +119,7 @@ export const useWalletSupportsChain = ( // This should obviously belong at hdwallet-core, and feature detection should be made async, with hdwallet-shapeshift-multichain able to do feature detection // programatically depending on whether the snaps is installed or not, but in the meantime, this will make things happy // If this evaluates to false, the wallet feature detection will be short circuit in supportsBTC, supportsCosmos and supports Thorchain methods - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const chainAccountIdsFilter = useMemo(() => ({ chainId }), [chainId]) const chainAccountIds = useAppSelector(state => diff --git a/src/pages/Lending/hooks/useLendingSupportedAssets/index.ts b/src/pages/Lending/hooks/useLendingSupportedAssets/index.ts index 753d8c21292..6cd2e910d7c 100644 --- a/src/pages/Lending/hooks/useLendingSupportedAssets/index.ts +++ b/src/pages/Lending/hooks/useLendingSupportedAssets/index.ts @@ -33,7 +33,7 @@ export const useLendingSupportedAssets = ({ statusFilter?: ThornodePoolStatuses | 'All' }) => { const wallet = useWallet().state.wallet - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const { data: availablePools } = useQuery({ ...reactQueries.thornode.poolsData(), diff --git a/src/pages/ThorChainLP/components/AddLiquidity/AddLiquidityInput.tsx b/src/pages/ThorChainLP/components/AddLiquidity/AddLiquidityInput.tsx index f6c8f0e39ab..87d57c74a0b 100644 --- a/src/pages/ThorChainLP/components/AddLiquidity/AddLiquidityInput.tsx +++ b/src/pages/ThorChainLP/components/AddLiquidity/AddLiquidityInput.tsx @@ -162,7 +162,7 @@ export const AddLiquidityInput: React.FC = ({ const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) const votingPower = useAppSelector(state => selectVotingPower(state, votingPowerParams)) const isSnapshotApiQueriesPending = useAppSelector(selectIsSnapshotApiQueriesPending) - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const isVotingPowerLoading = useMemo( () => isSnapshotApiQueriesPending, [isSnapshotApiQueriesPending], diff --git a/src/pages/ThorChainLP/components/RemoveLiquidity/RemoveLiquidityInput.tsx b/src/pages/ThorChainLP/components/RemoveLiquidity/RemoveLiquidityInput.tsx index 844434e2455..42c6a1bcc65 100644 --- a/src/pages/ThorChainLP/components/RemoveLiquidity/RemoveLiquidityInput.tsx +++ b/src/pages/ThorChainLP/components/RemoveLiquidity/RemoveLiquidityInput.tsx @@ -117,7 +117,7 @@ export const RemoveLiquidityInput: React.FC = ({ const translate = useTranslate() const { history: browserHistory } = useBrowserRouter() const wallet = useWallet().state.wallet - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const [slippageFiatUserCurrency, setSlippageFiatUserCurrency] = useState() const [isSlippageLoading, setIsSlippageLoading] = useState(false) diff --git a/src/utils/snaps.ts b/src/utils/snaps.ts index e1c04e2068a..4a2507049d9 100644 --- a/src/utils/snaps.ts +++ b/src/utils/snaps.ts @@ -1,6 +1,8 @@ +import detectEthereumProvider from '@metamask/detect-provider' import { enableShapeShiftSnap as _enableShapeShiftSnap } from '@shapeshiftoss/metamask-snaps-adapter' import assert from 'assert' import { getConfig } from 'config' +import type { Eip1193Provider } from 'ethers' export interface WalletEnableResult { accounts: string[] @@ -9,9 +11,34 @@ export interface WalletEnableResult { errors?: Error[] } -export const enableShapeShiftSnap = async (version: string = '1.0.9'): Promise => { +type GetSnapsResult = Record< + string, + { + version: string + id: string + enabled: boolean + blocked: boolean + } +> + +export const enableShapeShiftSnap = async (): Promise => { + const snapVersion = getConfig().REACT_APP_SNAP_VERSION const isSnapFeatureEnabled = getConfig().REACT_APP_EXPERIMENTAL_MM_SNAPPY_FINGERS assert(isSnapFeatureEnabled, 'Snap feature flag is disabled') const snapId = getConfig().REACT_APP_SNAP_ID - await _enableShapeShiftSnap(snapId, version) + await _enableShapeShiftSnap(snapId, snapVersion) +} + +export const getSnapVersion = async (): Promise => { + const snapId = getConfig().REACT_APP_SNAP_ID + const provider = (await detectEthereumProvider()) as Eip1193Provider + if (!(provider as any).isMetaMask) return null + + const snaps: GetSnapsResult = await provider.request({ + method: 'wallet_getSnaps', + }) + const snap = snaps[snapId] + if (!snap) return null + + return snap.version }