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 a520dac
Show file tree
Hide file tree
Showing 11 changed files with 109 additions and 45 deletions.
75 changes: 48 additions & 27 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,26 @@ 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]: {
address: 'https://blockstream.info/address/{address}',
},
[MultichainNetworks.BITCOIN_TESTNET]: {
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]: {
address: 'https://explorer.solana.com/{address}',
},
[MultichainNetworks.SOLANA_DEVNET]: {
address: 'https://explorer.solana.com/{address}?cluster=devnet',
},
[MultichainNetworks.SOLANA_TESTNET]: {
address: 'https://explorer.solana.com/{address}?cluster=testnet',
},
} as const;

export const MULTICHAIN_TOKEN_IMAGE_MAP = {
Expand All @@ -75,9 +92,11 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record<
type: 'rpc',
rpcPrefs: {
imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.BITCOIN],
blockExplorerUrl:
MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[MultichainNetworks.BITCOIN],
},
blockExplorerFormatUrls:
MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[
MultichainNetworks.BITCOIN
],
isAddressCompatible: isBtcMainnetAddress,
},
[MultichainNetworks.BITCOIN_TESTNET]: {
Expand All @@ -89,11 +108,11 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record<
type: 'rpc',
rpcPrefs: {
imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.BITCOIN],
blockExplorerUrl:
MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[
MultichainNetworks.BITCOIN_TESTNET
],
},
blockExplorerFormatUrls:
MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[
MultichainNetworks.BITCOIN_TESTNET
],
isAddressCompatible: isBtcTestnetAddress,
},
/**
Expand All @@ -108,9 +127,11 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record<
type: 'rpc',
rpcPrefs: {
imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA],
blockExplorerUrl:
MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[MultichainNetworks.SOLANA],
},
blockExplorerFormatUrls:
MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[
MultichainNetworks.SOLANA
],
isAddressCompatible: isSolanaAddress,
},
[MultichainNetworks.SOLANA_DEVNET]: {
Expand All @@ -122,11 +143,11 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record<
type: 'rpc',
rpcPrefs: {
imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA],
blockExplorerUrl:
MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[
MultichainNetworks.SOLANA_DEVNET
],
},
blockExplorerFormatUrls:
MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[
MultichainNetworks.SOLANA_DEVNET
],
isAddressCompatible: isSolanaAddress,
},
[MultichainNetworks.SOLANA_TESTNET]: {
Expand All @@ -138,11 +159,11 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record<
type: 'rpc',
rpcPrefs: {
imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA],
blockExplorerUrl:
MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[
MultichainNetworks.SOLANA_TESTNET
],
},
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.
21 changes: 21 additions & 0 deletions shared/lib/multichain/networks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type MultichainBlockExplorerFormatUrl<FormatToken extends string> =
`https://${string}.${string}/${string}{${FormatToken}}${string}`;

export type MultichainBlockExplorerFormatUrls = {
address: MultichainBlockExplorerFormatUrl<'address'>;
};

export function formatBlockExplorerUrl<FormatToken extends string>(
url: MultichainBlockExplorerFormatUrl<FormatToken>,
formatToken: FormatToken,
value: string,
) {
return url.replaceAll(formatToken, value);
}

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';

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

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

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

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

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

'formatBlockExplorerUrl' is defined but never used
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';

Check failure on line 12 in ui/components/multichain/menu-items/view-explorer-menu-item.test.tsx

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

`../../../../shared/lib/multichain/networks` import should occur before import of `.`

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 a520dac

Please sign in to comment.