Skip to content

Commit

Permalink
feat(multichain): add block explorer format URLs
Browse files Browse the repository at this point in the history
  • Loading branch information
ccharly committed Feb 4, 2025
1 parent 600e4b1 commit 67e8cd8
Show file tree
Hide file tree
Showing 12 changed files with 193 additions and 37 deletions.
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;
// 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
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
12 changes: 10 additions & 2 deletions ui/helpers/utils/multichain/blockExplorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 '';
};
2 changes: 1 addition & 1 deletion ui/selectors/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 67e8cd8

Please sign in to comment.