Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(multichain): add block explorer format URLs #30085

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 65 additions & 19 deletions shared/constants/multichain/networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Format" vs "Template"?

// NOTE: For now we use a callback to check if the address is compatible with
// the given network or not
isAddressCompatible: (address: string) => boolean;
Expand All @@ -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 = {
Expand All @@ -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]: {
Expand All @@ -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,
},
/**
Expand All @@ -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]: {
Expand All @@ -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]: {
Expand All @@ -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,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
isBtcMainnetAddress,
isBtcTestnetAddress,
isSolanaAddress,
} from './multichain';
} from './accounts';

const BTC_MAINNET_ADDRESSES = [
// P2WPKH
Expand Down
File renamed without changes.
31 changes: 31 additions & 0 deletions shared/lib/multichain/networks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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`,
);
});
});
58 changes: 58 additions & 0 deletions shared/lib/multichain/networks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* 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<Tag extends string> =
`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<Tag extends string>(
url: MultichainBlockExplorerFormatUrl<Tag>,
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);
}
2 changes: 1 addition & 1 deletion test/jest/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ 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,
MultichainNetworks,
} from '../../../../../shared/constants/multichain/networks';
import { createMockInternalAccount } from '../../../../../test/jest/mocks';
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
import { normalizeSafeAddress } from '../../../../../app/scripts/lib/multichain/address';
import { mockNetworkState } from '../../../../../test/stub/networks';
import { formatBlockExplorerAddressUrl } from '../../../../../shared/lib/multichain/networks';
import NicknamePopover from './nickname-popovers.component';

const mockAccount = createMockInternalAccount({
Expand Down Expand Up @@ -88,9 +89,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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import React from 'react';
import { CaipChainId } from '@metamask/utils';
import { CaipAssetType, TransactionStatus } from '@metamask/keyring-api';
import { screen, fireEvent } from '@testing-library/react';
import { shortenAddress } from '../../../helpers/utils/util';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { renderWithProvider } from '../../../../test/lib/render-helpers';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import { MultichainNetworks } from '../../../../shared/constants/multichain/networks';
import { MultichainTransactionDetailsModal } from './multichain-transaction-details-modal';
import { getTransactionUrl } from './helpers';
import { getTransactionUrl, shortenTransactionId } from './helpers';

jest.mock('../../../hooks/useI18nContext', () => ({
useI18nContext: jest.fn(),
Expand Down Expand Up @@ -117,8 +116,8 @@ describe('MultichainTransactionDetailsModal', () => {
it('shows transaction ID in shortened format', () => {
renderComponent();
const txId = mockTransaction.id;
const shortenedTxId = screen.getByText(shortenAddress(txId));
expect(shortenedTxId).toBeInTheDocument();
const shortenedId = screen.getByText(shortenTransactionId(txId));
expect(shortenedId).toBeInTheDocument();
});

it('displays network fee when present', () => {
Expand Down
13 changes: 9 additions & 4 deletions ui/components/app/transaction-list/transaction-list.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ 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 { formatBlockExplorerAddressUrl } from '../../../../shared/lib/multichain/networks';
import { ViewExplorerMenuItem } from '.';

const mockAccount = createMockInternalAccount({
Expand Down Expand Up @@ -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() };

Expand Down
2 changes: 1 addition & 1 deletion ui/components/ui/jazzicon/jazzicon.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading