diff --git a/shared/constants/multichain/networks.ts b/shared/constants/multichain/networks.ts index 659228ba1199..24dba5416c34 100644 --- a/shared/constants/multichain/networks.ts +++ b/shared/constants/multichain/networks.ts @@ -4,20 +4,27 @@ import { isBtcMainnetAddress, isBtcTestnetAddress, isSolanaAddress, -} from '../../lib/multichain'; +} from '../../lib/multichain/accounts'; +import { MultichainBlockExplorerFormatUrls } from '../../lib/multichain/networks'; export type ProviderConfigWithImageUrl = { rpcUrl?: string; type: string; ticker: string; nickname?: string; - rpcPrefs?: { blockExplorerUrl?: string; imageUrl?: string }; + rpcPrefs?: { + imageUrl?: string; + // Mainly for EVM. + blockExplorerUrl?: string; + }; id?: string; }; export type MultichainProviderConfig = ProviderConfigWithImageUrl & { nickname: string; chainId: CaipChainId; + // Variant of block explorer URLs for non-EVM. + blockExplorerFormatUrls?: MultichainBlockExplorerFormatUrls; // NOTE: For now we use a callback to check if the address is compatible with // the given network or not isAddressCompatible: (address: string) => boolean; @@ -42,16 +49,31 @@ export const MULTICHAIN_ACCOUNT_TYPE_TO_MAINNET = { export const BITCOIN_TOKEN_IMAGE_URL = './images/bitcoin-logo.svg'; export const SOLANA_TOKEN_IMAGE_URL = './images/solana-logo.svg'; -export const MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP = { - [MultichainNetworks.BITCOIN]: 'https://blockstream.info/address', - [MultichainNetworks.BITCOIN_TESTNET]: - 'https://blockstream.info/testnet/address', +export const MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP: Record< + CaipChainId, + MultichainBlockExplorerFormatUrls +> = { + [MultichainNetworks.BITCOIN]: { + url: 'https://blockstream.info', + address: 'https://blockstream.info/address/{address}', + }, + [MultichainNetworks.BITCOIN_TESTNET]: { + url: 'https://blockstream.info', + address: 'https://blockstream.info/testnet/address/{address}', + }, - [MultichainNetworks.SOLANA]: 'https://explorer.solana.com/', - [MultichainNetworks.SOLANA_DEVNET]: - 'https://explorer.solana.com/?cluster=devnet', - [MultichainNetworks.SOLANA_TESTNET]: - 'https://explorer.solana.com/?cluster=testnet', + [MultichainNetworks.SOLANA]: { + url: 'https://explorer.solana.com', + address: 'https://explorer.solana.com/{address}', + }, + [MultichainNetworks.SOLANA_DEVNET]: { + url: 'https://explorer.solana.com', + address: 'https://explorer.solana.com/{address}?cluster=devnet', + }, + [MultichainNetworks.SOLANA_TESTNET]: { + url: 'https://explorer.solana.com', + address: 'https://explorer.solana.com/{address}?cluster=testnet', + }, } as const; export const MULTICHAIN_TOKEN_IMAGE_MAP = { @@ -76,8 +98,14 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< rpcPrefs: { imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.BITCOIN], blockExplorerUrl: - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[MultichainNetworks.BITCOIN], + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.BITCOIN + ].url, }, + blockExplorerFormatUrls: + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.BITCOIN + ], isAddressCompatible: isBtcMainnetAddress, }, [MultichainNetworks.BITCOIN_TESTNET]: { @@ -90,10 +118,14 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< rpcPrefs: { imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.BITCOIN], blockExplorerUrl: - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[ + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ MultichainNetworks.BITCOIN_TESTNET - ], + ].url, }, + blockExplorerFormatUrls: + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.BITCOIN_TESTNET + ], isAddressCompatible: isBtcTestnetAddress, }, /** @@ -109,8 +141,14 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< rpcPrefs: { imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA], blockExplorerUrl: - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[MultichainNetworks.SOLANA], + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.SOLANA + ].url, }, + blockExplorerFormatUrls: + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.SOLANA + ], isAddressCompatible: isSolanaAddress, }, [MultichainNetworks.SOLANA_DEVNET]: { @@ -123,10 +161,14 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< rpcPrefs: { imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA], blockExplorerUrl: - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[ + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ MultichainNetworks.SOLANA_DEVNET - ], + ].url, }, + blockExplorerFormatUrls: + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.SOLANA_DEVNET + ], isAddressCompatible: isSolanaAddress, }, [MultichainNetworks.SOLANA_TESTNET]: { @@ -139,10 +181,14 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< rpcPrefs: { imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA], blockExplorerUrl: - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[ + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ MultichainNetworks.SOLANA_TESTNET - ], + ].url, }, + blockExplorerFormatUrls: + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.SOLANA_TESTNET + ], isAddressCompatible: isSolanaAddress, }, }; diff --git a/shared/lib/multichain.test.ts b/shared/lib/multichain/accounts.test.ts similarity index 99% rename from shared/lib/multichain.test.ts rename to shared/lib/multichain/accounts.test.ts index 6e8abd726fb8..10b41425d5b0 100644 --- a/shared/lib/multichain.test.ts +++ b/shared/lib/multichain/accounts.test.ts @@ -4,7 +4,7 @@ import { isBtcMainnetAddress, isBtcTestnetAddress, isSolanaAddress, -} from './multichain'; +} from './accounts'; const BTC_MAINNET_ADDRESSES = [ // P2WPKH diff --git a/shared/lib/multichain.ts b/shared/lib/multichain/accounts.ts similarity index 100% rename from shared/lib/multichain.ts rename to shared/lib/multichain/accounts.ts diff --git a/shared/lib/multichain/networks.test.ts b/shared/lib/multichain/networks.test.ts new file mode 100644 index 000000000000..c008e8745179 --- /dev/null +++ b/shared/lib/multichain/networks.test.ts @@ -0,0 +1,26 @@ +import { formatBlockExplorerUrl, formatBlockExplorerAddressUrl, MultichainBlockExplorerFormatUrls } from './networks'; + +describe('multichain - networks', () => { + it('formats a URL', () => { + const value = 'something-else'; + + expect(formatBlockExplorerUrl( + 'https://foo.bar/show/{foobar}/1', + '{foobar}', + value, + )).toBe(`https://foo.bar/show/${value}/1`); + }); + + it('formats a URL for an address', () => { + const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + const urls: MultichainBlockExplorerFormatUrls = { + url: 'https://foo.bar', + address: 'https://foo.bar/address/{address}?detail=true', + }; + + expect(formatBlockExplorerAddressUrl( + urls, + address, + )).toBe(`https://foo.bar/address/${address}?detail=true`); + }); +}); \ No newline at end of file diff --git a/shared/lib/multichain/networks.ts b/shared/lib/multichain/networks.ts new file mode 100644 index 000000000000..f50cfad4c6ff --- /dev/null +++ b/shared/lib/multichain/networks.ts @@ -0,0 +1,59 @@ +/** + * Base URL of a block explorer. + */ +export type MultichainBlockExplorerUrl = + `https://${string}.${string}`; + +/** + * Format URL of a block explorer for addresses. + * + * The format URLs can be used to "expand" some strings within the format string. (Similar + * to "string interpolation"). The "tags" are being identified with curly-braces. + */ +export type MultichainBlockExplorerFormatUrl = + `https://${string}.${string}/${string}{${Tag}}${string}`; + +/** + * A group of URL and format URL for block explorers. + */ +export type MultichainBlockExplorerFormatUrls = { + /** + * Base URL of the block explorer. + */ + url: MultichainBlockExplorerUrl; + + /** + * Format URL of the block explorer for addresses. + */ + address: MultichainBlockExplorerFormatUrl<'address'>; +}; + +/** + * Format a URL by replacing a "tag" with a corresponding value. + * + * @param url - Format URL. + * @param tag - Format URL tag. + * @param value - The value to expand. + * @returns A formatted URL. + */ +export function formatBlockExplorerUrl( + url: MultichainBlockExplorerFormatUrl, + tag: Tag, + value: string, +) { + return url.replaceAll(tag, value); +} + +/** + * Format a URL for addresses. + * + * @param urls - The group of format URLs for a given block explorer. + * @param address - The address to create the URL for. + * @returns The formatted URL for the given address. + */ +export function formatBlockExplorerAddressUrl( + urls: MultichainBlockExplorerFormatUrls, + address: string, +) { + return formatBlockExplorerUrl(urls.address, '{address}', address); +} diff --git a/test/jest/mocks.ts b/test/jest/mocks.ts index 9d6bff9991f7..07d8ecda1fb6 100644 --- a/test/jest/mocks.ts +++ b/test/jest/mocks.ts @@ -22,7 +22,7 @@ import { } from '../../ui/ducks/send'; import { MetaMaskReduxState } from '../../ui/store/store'; import mockState from '../data/mock-state.json'; -import { isBtcMainnetAddress } from '../../shared/lib/multichain'; +import { isBtcMainnetAddress } from '../../shared/lib/multichain/accounts'; export type MockState = typeof mockState; diff --git a/ui/components/app/modals/nickname-popovers/nickname-popovers.component.test.tsx b/ui/components/app/modals/nickname-popovers/nickname-popovers.component.test.tsx index 20d95f59a4f9..1a4ccd787127 100644 --- a/ui/components/app/modals/nickname-popovers/nickname-popovers.component.test.tsx +++ b/ui/components/app/modals/nickname-popovers/nickname-popovers.component.test.tsx @@ -5,7 +5,8 @@ import { renderWithProvider } from '../../../../../test/jest'; import configureStore from '../../../../store/store'; import mockState from '../../../../../test/data/mock-state.json'; import { - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP, + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP, + MULTICHAIN_PROVIDER_CONFIGS, MultichainNetworks, } from '../../../../../shared/constants/multichain/networks'; import { createMockInternalAccount } from '../../../../../test/jest/mocks'; @@ -13,6 +14,7 @@ import { createMockInternalAccount } from '../../../../../test/jest/mocks'; // eslint-disable-next-line import/no-restricted-paths import { normalizeSafeAddress } from '../../../../../app/scripts/lib/multichain/address'; import { mockNetworkState } from '../../../../../test/stub/networks'; +import { formatBlockExplorerAddressUrl, formatBlockExplorerUrl } from '../../../../../shared/lib/multichain/networks'; import NicknamePopover from './nickname-popovers.component'; const mockAccount = createMockInternalAccount({ @@ -88,9 +90,12 @@ describe('NicknamePopover', () => { it('opens non-EVM block explorer', () => { global.platform = { openTab: jest.fn(), closeCurrentWindow: jest.fn() }; - const expectedExplorerUrl = `${ - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[MultichainNetworks.BITCOIN] - }/${normalizeSafeAddress(mockNonEvmAccount.address)}`; + const expectedExplorerUrl = formatBlockExplorerAddressUrl( + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.BITCOIN + ], + mockNonEvmAccount.address, + ); const { getByText } = render({ props: { address: mockNonEvmAccount.address }, diff --git a/ui/components/app/transaction-list/transaction-list.test.js b/ui/components/app/transaction-list/transaction-list.test.js index 1009a43cbd5d..48e42fb2c309 100644 --- a/ui/components/app/transaction-list/transaction-list.test.js +++ b/ui/components/app/transaction-list/transaction-list.test.js @@ -11,9 +11,10 @@ import { MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import { - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP, + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP, MultichainNetworks, } from '../../../../shared/constants/multichain/networks'; +import { formatBlockExplorerAddressUrl } from '../../../../shared/lib/multichain/networks'; import TransactionList from './transaction-list.component'; const defaultState = { @@ -96,9 +97,13 @@ describe('TransactionList', () => { }); expect(viewOnExplorerBtn).toBeInTheDocument(); - const blockExplorerDomain = new URL( - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[MultichainNetworks.BITCOIN], - ).host; + const blockExplorerUrl = formatBlockExplorerAddressUrl( + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.BITCOIN + ], + btcState.metamask.internalAccounts.selectedAccount.address, + ); + const blockExplorerDomain = new URL(blockExplorerUrl).host; fireEvent.click(viewOnExplorerBtn); expect(mockTrackEvent).toHaveBeenCalledWith({ event: MetaMetricsEventName.ExternalLinkClicked, diff --git a/ui/components/multichain/menu-items/view-explorer-menu-item.test.tsx b/ui/components/multichain/menu-items/view-explorer-menu-item.test.tsx index 91428dff06e9..dce7aaf0377f 100644 --- a/ui/components/multichain/menu-items/view-explorer-menu-item.test.tsx +++ b/ui/components/multichain/menu-items/view-explorer-menu-item.test.tsx @@ -5,10 +5,11 @@ import configureStore from '../../../store/store'; import mockState from '../../../../test/data/mock-state.json'; import { createMockInternalAccount } from '../../../../test/jest/mocks'; import { - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP, + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP, MultichainNetworks, } from '../../../../shared/constants/multichain/networks'; import { ViewExplorerMenuItem } from '.'; +import { formatBlockExplorerAddressUrl } from '../../../../shared/lib/multichain/networks'; const mockAccount = createMockInternalAccount({ name: 'Account 1', @@ -52,9 +53,12 @@ describe('ViewExplorerMenuItem', () => { }); it('renders "View on explorer" for non-EVM account', () => { - const expectedExplorerUrl = `${ - MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[MultichainNetworks.BITCOIN] - }/${mockNonEvmAccount.address}`; + const expectedExplorerUrl = formatBlockExplorerAddressUrl( + MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ + MultichainNetworks.BITCOIN + ], + mockNonEvmAccount.address, + ); const expectedExplorerUrlHost = new URL(expectedExplorerUrl).host; global.platform = { openTab: jest.fn(), closeCurrentWindow: jest.fn() }; diff --git a/ui/components/ui/jazzicon/jazzicon.component.tsx b/ui/components/ui/jazzicon/jazzicon.component.tsx index c32789740f36..e58d7229dec8 100644 --- a/ui/components/ui/jazzicon/jazzicon.component.tsx +++ b/ui/components/ui/jazzicon/jazzicon.component.tsx @@ -4,7 +4,7 @@ import { KnownCaipNamespace, stringToBytes } from '@metamask/utils'; import iconFactoryGenerator, { IconFactory, } from '../../../helpers/utils/icon-factory'; -import { getCaipNamespaceFromAddress } from '../../../../shared/lib/multichain'; +import { getCaipNamespaceFromAddress } from '../../../../shared/lib/multichain/accounts'; /** * Generates a seed for Jazzicon based on the provided address. diff --git a/ui/helpers/utils/multichain/blockExplorer.ts b/ui/helpers/utils/multichain/blockExplorer.ts index c61c10339df2..606f15850ad6 100644 --- a/ui/helpers/utils/multichain/blockExplorer.ts +++ b/ui/helpers/utils/multichain/blockExplorer.ts @@ -4,6 +4,8 @@ import { MultichainNetwork } from '../../../selectors/multichain'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { normalizeSafeAddress } from '../../../../app/scripts/lib/multichain/address'; +import { MultichainProviderConfig } from '../../../../shared/constants/multichain/networks'; +import { formatBlockExplorerAddressUrl } from '../../../../shared/lib/multichain/networks'; export const getMultichainBlockExplorerUrl = ( network: MultichainNetwork, @@ -24,6 +26,12 @@ export const getMultichainAccountUrl = ( ); } - const multichainExplorerUrl = getMultichainBlockExplorerUrl(network); - return multichainExplorerUrl ? `${multichainExplorerUrl}/${address}` : ''; + // We're in a non-EVM context, so we assume we can use format URLs instead. + const { blockExplorerFormatUrls } = + network.network as MultichainProviderConfig; + if (blockExplorerFormatUrls) { + return formatBlockExplorerAddressUrl(blockExplorerFormatUrls, address); + } + + return ''; }; diff --git a/ui/selectors/accounts.ts b/ui/selectors/accounts.ts index 7515a68e04e5..8e5e18aa63a2 100644 --- a/ui/selectors/accounts.ts +++ b/ui/selectors/accounts.ts @@ -8,7 +8,7 @@ import { AccountsControllerState } from '@metamask/accounts-controller'; import { isBtcMainnetAddress, isBtcTestnetAddress, -} from '../../shared/lib/multichain'; +} from '../../shared/lib/multichain/accounts'; export type AccountsState = { metamask: AccountsControllerState;