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 2e7c23b
Show file tree
Hide file tree
Showing 12 changed files with 190 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.
26 changes: 26 additions & 0 deletions shared/lib/multichain/networks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { formatBlockExplorerUrl, formatBlockExplorerAddressUrl, MultichainBlockExplorerFormatUrls } from './networks';

Check failure on line 1 in shared/lib/multichain/networks.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Replace `·formatBlockExplorerUrl,·formatBlockExplorerAddressUrl,·MultichainBlockExplorerFormatUrls·` with `⏎··formatBlockExplorerUrl,⏎··formatBlockExplorerAddressUrl,⏎··MultichainBlockExplorerFormatUrls,⏎`

describe('multichain - networks', () => {
it('formats a URL', () => {
const value = 'something-else';

expect(formatBlockExplorerUrl(

Check failure on line 7 in shared/lib/multichain/networks.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Insert `⏎······`
'https://foo.bar/show/{foobar}/1',

Check failure on line 8 in shared/lib/multichain/networks.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Insert `··`
'{foobar}',

Check failure on line 9 in shared/lib/multichain/networks.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Insert `··`
value,

Check failure on line 10 in shared/lib/multichain/networks.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Insert `··`
)).toBe(`https://foo.bar/show/${value}/1`);

Check failure on line 11 in shared/lib/multichain/networks.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Replace `····)` with `······),⏎····`
});

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(

Check failure on line 21 in shared/lib/multichain/networks.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Replace `⏎······urls,⏎······address,⏎····)).toBe(`https://foo.bar/address/${address}?detail=true`` with `urls,·address)).toBe(⏎······`https://foo.bar/address/${address}?detail=true`,⏎····`
urls,
address,
)).toBe(`https://foo.bar/address/${address}?detail=true`);
});
});

Check failure on line 26 in shared/lib/multichain/networks.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Insert `⏎`
59 changes: 59 additions & 0 deletions shared/lib/multichain/networks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Base URL of a block explorer.
*/
export type MultichainBlockExplorerUrl =

Check failure on line 4 in shared/lib/multichain/networks.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Delete `⏎·`
`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,16 @@ 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,

Check failure on line 9 in ui/components/app/modals/nickname-popovers/nickname-popovers.component.test.tsx

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

'MULTICHAIN_PROVIDER_CONFIGS' is defined but never used
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, formatBlockExplorerUrl } from '../../../../../shared/lib/multichain/networks';
import NicknamePopover from './nickname-popovers.component';

const mockAccount = createMockInternalAccount({
Expand Down Expand Up @@ -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 },
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,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',
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 2e7c23b

Please sign in to comment.