feat: Create chain search and override widgets (#4486)

### Description

- Widgets: Create `ChainSearchMenu` and `ChainDetailsMenu` components
- Widgets: Add required icon and button components
- Widgets: Add persisted zustand store and hooks
- Widgets: Add clipboard utility functions
- Utils: Migrate `fetchWithTimeout` from widgets to utils
- Utils: Add `objSlice` function and improve types for `objMerge`
- Utils: Add `isUrl` function
- SDK: Break out BlockExplorerSchema and export separately
- SDK: Migrate RPC + Explorer health tests back to SDK from registry

### Related issues

Prerequisite for:
- https://github.com/hyperlane-xyz/hyperlane-warp-ui-template/issues/238
- https://github.com/hyperlane-xyz/hyperlane-explorer/issues/108
- https://github.com/hyperlane-xyz/hyperlane-warp-ui-template/issues/215

Helps with:
- https://github.com/hyperlane-xyz/issues/issues/1160
- https://github.com/hyperlane-xyz/issues/issues/1234

### Backward compatibility

Yes

### Testing

New unit and storybook tests
pull/4648/head
J M Rossy 1 month ago committed by GitHub
parent 9c6f80cbe2
commit 2afc484a2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/calm-pears-clean.md
  2. 7
      .changeset/clean-frogs-chew.md
  3. 8
      .changeset/shy-zebras-invent.md
  4. 39
      typescript/sdk/src/index.ts
  5. 33
      typescript/sdk/src/metadata/blockExplorer.ts
  6. 71
      typescript/sdk/src/metadata/chainMetadataTypes.ts
  7. 63
      typescript/sdk/src/providers/explorerHealthTest.ts
  8. 85
      typescript/sdk/src/providers/rpcHealthTest.ts
  9. 22
      typescript/utils/src/async.ts
  10. 6
      typescript/utils/src/index.ts
  11. 49
      typescript/utils/src/objects.test.ts
  12. 89
      typescript/utils/src/objects.ts
  13. 18
      typescript/utils/src/result.ts
  14. 9
      typescript/utils/src/url.ts
  15. 17
      typescript/utils/src/yaml.ts
  16. 6
      typescript/widgets/.storybook/main.ts
  17. 10
      typescript/widgets/package.json
  18. 189
      typescript/widgets/src/chains/ChainAddMenu.tsx
  19. 505
      typescript/widgets/src/chains/ChainDetailsMenu.tsx
  20. 21
      typescript/widgets/src/chains/ChainLogo.tsx
  21. 322
      typescript/widgets/src/chains/ChainSearchMenu.tsx
  22. 4
      typescript/widgets/src/chains/types.ts
  23. 3
      typescript/widgets/src/color.ts
  24. 21
      typescript/widgets/src/components/Button.tsx
  25. 51
      typescript/widgets/src/components/CopyButton.tsx
  26. 24
      typescript/widgets/src/components/IconButton.tsx
  27. 21
      typescript/widgets/src/components/LinkButton.tsx
  28. 344
      typescript/widgets/src/components/SearchMenu.tsx
  29. 51
      typescript/widgets/src/components/SegmentedControl.tsx
  30. 25
      typescript/widgets/src/components/TextInput.tsx
  31. 28
      typescript/widgets/src/components/Tooltip.tsx
  32. 17
      typescript/widgets/src/icons/Airplane.tsx
  33. 45
      typescript/widgets/src/icons/Arrow.tsx
  34. 24
      typescript/widgets/src/icons/BoxArrow.tsx
  35. 18
      typescript/widgets/src/icons/Checkmark.tsx
  36. 46
      typescript/widgets/src/icons/Chevron.tsx
  37. 6
      typescript/widgets/src/icons/Circle.tsx
  38. 34
      typescript/widgets/src/icons/Copy.tsx
  39. 17
      typescript/widgets/src/icons/Envelope.tsx
  40. 18
      typescript/widgets/src/icons/Filter.tsx
  41. 18
      typescript/widgets/src/icons/Funnel.tsx
  42. 18
      typescript/widgets/src/icons/Gear.tsx
  43. 17
      typescript/widgets/src/icons/Lock.tsx
  44. 23
      typescript/widgets/src/icons/Pencil.tsx
  45. 18
      typescript/widgets/src/icons/Plus.tsx
  46. 18
      typescript/widgets/src/icons/PlusCircle.tsx
  47. 19
      typescript/widgets/src/icons/QuestionMark.tsx
  48. 18
      typescript/widgets/src/icons/Search.tsx
  49. 17
      typescript/widgets/src/icons/Shield.tsx
  50. 33
      typescript/widgets/src/icons/Spinner.tsx
  51. 19
      typescript/widgets/src/icons/UpDownArrows.tsx
  52. 19
      typescript/widgets/src/icons/WideChevron.tsx
  53. 18
      typescript/widgets/src/icons/X.tsx
  54. 5
      typescript/widgets/src/icons/types.ts
  55. 45
      typescript/widgets/src/index.ts
  56. 45
      typescript/widgets/src/layout/DropdownMenu.tsx
  57. 74
      typescript/widgets/src/layout/Modal.tsx
  58. 47
      typescript/widgets/src/layout/Popover.tsx
  59. 25
      typescript/widgets/src/logos/Hyperlane.tsx
  60. 2
      typescript/widgets/src/messages/useMessageStage.ts
  61. 42
      typescript/widgets/src/stories/ChainDetailsMenu.stories.tsx
  62. 11
      typescript/widgets/src/stories/ChainLogo.stories.tsx
  63. 48
      typescript/widgets/src/stories/ChainSearchMenu.stories.tsx
  64. 36
      typescript/widgets/src/stories/Modal.stories.tsx
  65. 13
      typescript/widgets/src/styles.css
  66. 24
      typescript/widgets/src/utils/clipboard.ts
  67. 3
      typescript/widgets/src/utils/explorers.ts
  68. 14
      typescript/widgets/src/utils/timeout.ts
  69. 32
      typescript/widgets/src/utils/useChainConnectionTest.ts
  70. 22
      typescript/widgets/tailwind.config.cjs
  71. 216
      yarn.lock

@ -0,0 +1,6 @@
---
'@hyperlane-xyz/sdk': minor
---
Break out BlockExplorerSchema and export separately
Migrate RPC + Explorer health tests back to SDK from registry

@ -0,0 +1,7 @@
---
'@hyperlane-xyz/utils': minor
---
Migrate fetchWithTimeout from widgets to utils
Add objSlice function and improve types for objMerge
Add isUrl function

@ -0,0 +1,8 @@
---
'@hyperlane-xyz/widgets': minor
---
Create ChainSearchMenu and ChainDetailsMenu components
Add required icon and button components
Add persisted zustand store and hooks
Add clipboard utility functions

@ -27,10 +27,10 @@ export {
testSealevelChain,
} from './consts/testChains.js';
export {
attachAndConnectContracts,
attachContracts,
attachContractsMap,
attachContractsMapAndGetForeignDeployments,
attachAndConnectContracts,
connectContracts,
connectContractsMap,
filterAddressesMap,
@ -69,8 +69,8 @@ export {
export { MultiProtocolCore } from './core/MultiProtocolCore.js';
export {
CoreConfigSchema,
DeployedCoreAddressesSchema,
DeployedCoreAddresses,
DeployedCoreAddressesSchema,
} from './core/schemas.js';
export { TestCoreApp } from './core/TestCoreApp.js';
export { TestCoreDeployer } from './core/TestCoreDeployer.js';
@ -195,6 +195,7 @@ export {
} from './metadata/ChainMetadataManager.js';
export {
BlockExplorer,
BlockExplorerSchema,
ChainMetadata,
ChainMetadataSchema,
ChainMetadataSchemaObject,
@ -208,6 +209,8 @@ export {
getDomainId,
getReorgPeriod,
isValidChainMetadata,
mergeChainMetadata,
mergeChainMetadataMap,
} from './metadata/chainMetadataTypes.js';
export { ZChainName, ZHash } from './metadata/customZodTypes.js';
export {
@ -254,6 +257,7 @@ export {
InterchainQueryConfig,
InterchainQueryDeployer,
} from './middleware/query/InterchainQueryDeployer.js';
export { isBlockExplorerHealthy } from './providers/explorerHealthTest.js';
export {
MultiProtocolProvider,
MultiProtocolProviderOptions,
@ -302,7 +306,12 @@ export {
ViemTransaction,
ViemTransactionReceipt,
} from './providers/ProviderType.js';
export { ProviderRetryOptions } from './providers/SmartProvider/types.js';
export {
isCosmJsProviderHealthy,
isEthersV5ProviderHealthy,
isRpcHealthy,
isSolanaWeb3ProviderHealthy,
} from './providers/rpcHealthTest.js';
export { HyperlaneEtherscanProvider } from './providers/SmartProvider/HyperlaneEtherscanProvider.js';
export { HyperlaneJsonRpcProvider } from './providers/SmartProvider/HyperlaneJsonRpcProvider.js';
export {
@ -312,6 +321,10 @@ export {
excludeProviderMethods,
} from './providers/SmartProvider/ProviderMethods.js';
export { HyperlaneSmartProvider } from './providers/SmartProvider/SmartProvider.js';
export {
ProviderRetryOptions,
SmartProviderOptions,
} from './providers/SmartProvider/types.js';
export { CallData } from './providers/transactions/types.js';
export { SubmitterMetadataSchema } from './providers/transactions/submitter/schemas.js';
@ -329,17 +342,17 @@ export {
} from './providers/transactions/submitter/ethersV5/types.js';
export {
SubmissionStrategySchema,
ChainSubmissionStrategySchema,
SubmissionStrategySchema,
} from './providers/transactions/submitter/builder/schemas.js';
export { TxSubmitterBuilder } from './providers/transactions/submitter/builder/TxSubmitterBuilder.js';
export {
SubmissionStrategy,
ChainSubmissionStrategy,
SubmissionStrategy,
} from './providers/transactions/submitter/builder/types.js';
export { EV5GnosisSafeTxSubmitter } from './providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.js';
export { EV5GnosisSafeTxBuilder } from './providers/transactions/submitter/ethersV5/EV5GnosisSafeTxBuilder.js';
export { EV5GnosisSafeTxSubmitter } from './providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.js';
export { EV5ImpersonatedAccountTxSubmitter } from './providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.js';
export { EV5JsonRpcTxSubmitter } from './providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.js';
export { EV5TxSubmitterInterface } from './providers/transactions/submitter/ethersV5/EV5TxSubmitterInterface.js';
@ -379,11 +392,11 @@ export {
MailboxClientConfig,
ProxiedFactories,
ProxiedRouterConfig,
RemoteRouters,
RouterAddress,
RouterConfig,
RouterViolation,
RouterViolationType,
RemoteRouters,
proxiedFactories,
} from './router/types.js';
export {
@ -462,6 +475,7 @@ export {
TOKEN_TYPE_TO_STANDARD,
TokenStandard,
} from './token/TokenStandard.js';
export { TokenRouterConfig, WarpRouteDeployConfig } from './token/types.js';
export { ChainMap, ChainName, ChainNameOrId, Connection } from './types.js';
export { getCosmosRegistryChain } from './utils/cosmos.js';
export { filterByChains } from './utils/filter.js';
@ -473,9 +487,8 @@ export {
setFork,
stopImpersonatingAccount,
} from './utils/fork.js';
export { MultiGeneric } from './utils/MultiGeneric.js';
export { TokenRouterConfig, WarpRouteDeployConfig } from './token/types.js';
export { multisigIsmVerificationCost } from './utils/ism.js';
export { MultiGeneric } from './utils/MultiGeneric.js';
export {
SealevelAccountDataWrapper,
SealevelInstructionWrapper,
@ -515,11 +528,11 @@ export {
} from './utils/gnosisSafe.js';
export { EvmCoreModule } from './core/EvmCoreModule.js';
export { EvmIsmModule } from './ism/EvmIsmModule.js';
export { EvmERC20WarpModule } from './token/EvmERC20WarpModule.js';
export { proxyAdmin } from './deploy/proxy.js';
export {
ProxyFactoryFactoriesSchema,
ProxyFactoryFactoriesAddresses,
ProxyFactoryFactoriesSchema,
} from './deploy/schemas.js';
export { EvmIsmModule } from './ism/EvmIsmModule.js';
export { AnnotatedEV5Transaction } from './providers/ProviderType.js';
export { proxyAdmin } from './deploy/proxy.js';
export { EvmERC20WarpModule } from './token/EvmERC20WarpModule.js';

@ -2,13 +2,19 @@ import { ProtocolType } from '@hyperlane-xyz/utils';
import { ChainMetadata, ExplorerFamily } from './chainMetadataTypes.js';
export function getExplorerBaseUrl(metadata: ChainMetadata): string | null {
export function getExplorerBaseUrl(
metadata: ChainMetadata,
index = 0,
): string | null {
if (!metadata?.blockExplorers?.length) return null;
const url = new URL(metadata.blockExplorers[0].url);
const url = new URL(metadata.blockExplorers[index].url);
return url.toString();
}
export function getExplorerApi(metadata: ChainMetadata): {
export function getExplorerApi(
metadata: ChainMetadata,
index = 0,
): {
apiUrl: string;
apiKey?: string | undefined;
family?: ExplorerFamily | undefined;
@ -16,20 +22,21 @@ export function getExplorerApi(metadata: ChainMetadata): {
const { protocol, blockExplorers } = metadata;
// TODO solana + cosmos support here as needed
if (protocol !== ProtocolType.Ethereum) return null;
if (!blockExplorers?.length || !blockExplorers[0].apiUrl) return null;
if (!blockExplorers?.length || !blockExplorers[index].apiUrl) return null;
return {
apiUrl: blockExplorers[0].apiUrl,
apiKey: blockExplorers[0].apiKey,
family: blockExplorers[0].family,
apiUrl: blockExplorers[index].apiUrl,
apiKey: blockExplorers[index].apiKey,
family: blockExplorers[index].family,
};
}
export function getExplorerApiUrl(metadata: ChainMetadata): string | null {
const { protocol, blockExplorers } = metadata;
// TODO solana + cosmos support here as needed
if (protocol !== ProtocolType.Ethereum) return null;
if (!blockExplorers?.length || !blockExplorers[0].apiUrl) return null;
const { apiUrl, apiKey } = blockExplorers[0];
export function getExplorerApiUrl(
metadata: ChainMetadata,
index = 0,
): string | null {
const explorer = getExplorerApi(metadata, index)!;
if (!explorer) return null;
const { apiUrl, apiKey } = explorer;
if (!apiKey) return apiUrl;
const url = new URL(apiUrl);
url.searchParams.set('apikey', apiKey);

@ -4,7 +4,9 @@
*/
import { SafeParseReturnType, z } from 'zod';
import { ProtocolType } from '@hyperlane-xyz/utils';
import { ProtocolType, objMerge } from '@hyperlane-xyz/utils';
import { ChainMap } from '../types.js';
import { ZChainName, ZNzUint, ZUint } from './customZodTypes.js';
@ -67,6 +69,29 @@ export const RpcUrlSchema = z.object({
export type RpcUrl = z.infer<typeof RpcUrlSchema>;
export const BlockExplorerSchema = z.object({
name: z.string().describe('A human readable name for the explorer.'),
url: z.string().url().describe('The base URL for the explorer.'),
apiUrl: z
.string()
.url()
.describe('The base URL for requests to the explorer API.'),
apiKey: z
.string()
.optional()
.describe(
'An API key for the explorer (recommended for better reliability).',
),
family: z
.nativeEnum(ExplorerFamily)
.optional()
.describe(
'The type of the block explorer. See ExplorerFamily for valid values.',
),
});
export type BlockExplorer = z.infer<typeof BlockExplorerSchema>;
export const NativeTokenSchema = z.object({
name: z.string(),
symbol: z.string(),
@ -87,28 +112,7 @@ export const ChainMetadataSchemaObject = z.object({
.describe('The human readable address prefix for the chains using bech32.'),
blockExplorers: z
.array(
z.object({
name: z.string().describe('A human readable name for the explorer.'),
url: z.string().url().describe('The base URL for the explorer.'),
apiUrl: z
.string()
.url()
.describe('The base URL for requests to the explorer API.'),
apiKey: z
.string()
.optional()
.describe(
'An API key for the explorer (recommended for better reliability).',
),
family: z
.nativeEnum(ExplorerFamily)
.optional()
.describe(
'The type of the block explorer. See ExplorerFamily for valid values.',
),
}),
)
.array(BlockExplorerSchema)
.optional()
.describe('A list of block explorers with data for this chain'),
@ -230,7 +234,7 @@ export const ChainMetadataSchemaObject = z.object({
rpcUrls: z
.array(RpcUrlSchema)
.nonempty()
.min(1)
.describe('The list of RPC endpoints for interacting with the chain.'),
slip44: z.number().optional().describe('The SLIP-0044 coin type.'),
@ -341,11 +345,6 @@ export type ChainMetadata<Ext = object> = z.infer<
> &
Ext;
export type BlockExplorer = Exclude<
ChainMetadata['blockExplorers'],
undefined
>[number];
export function safeParseChainMetadata(
c: ChainMetadata,
): SafeParseReturnType<ChainMetadata, ChainMetadata> {
@ -373,3 +372,17 @@ export function getReorgPeriod(chainMetadata: ChainMetadata): number {
return chainMetadata.blocks.reorgPeriod;
else throw new Error('Chain has no reorg period');
}
export function mergeChainMetadata(
base: ChainMetadata,
overrides: Partial<ChainMetadata> | undefined,
): ChainMetadata {
return objMerge<ChainMetadata>(base, overrides || {}, 10, true);
}
export function mergeChainMetadataMap(
base: ChainMap<ChainMetadata>,
overrides: ChainMap<Partial<ChainMetadata> | undefined> | undefined,
): ChainMap<ChainMetadata> {
return objMerge<ChainMap<ChainMetadata>>(base, overrides || {}, 10, true);
}

@ -0,0 +1,63 @@
import { ChainMetadata } from '@hyperlane-xyz/sdk';
import { Address, ProtocolType, rootLogger } from '@hyperlane-xyz/utils';
import {
getExplorerAddressUrl,
getExplorerBaseUrl,
getExplorerTxUrl,
} from '../metadata/blockExplorer.js';
const PROTOCOL_TO_ADDRESS: Record<ProtocolType, Address> = {
[ProtocolType.Ethereum]: '0x0000000000000000000000000000000000000000',
[ProtocolType.Sealevel]: '11111111111111111111111111111111',
[ProtocolType.Cosmos]: 'cosmos100000000000000000000000000000000000000',
};
const PROTOCOL_TO_TX_HASH: Partial<Record<ProtocolType, Address>> = {
[ProtocolType.Ethereum]:
'0x0000000000000000000000000000000000000000000000000000000000000000',
[ProtocolType.Cosmos]:
'0000000000000000000000000000000000000000000000000000000000000000',
};
export async function isBlockExplorerHealthy(
chainMetadata: ChainMetadata,
explorerIndex: number,
address?: Address,
txHash?: string,
): Promise<boolean> {
const baseUrl = getExplorerBaseUrl(chainMetadata, explorerIndex);
address ??= PROTOCOL_TO_ADDRESS[chainMetadata.protocol];
txHash ??= PROTOCOL_TO_TX_HASH[chainMetadata.protocol];
if (!baseUrl) return false;
rootLogger.debug(`Got base url: ${baseUrl}`);
rootLogger.debug(`Checking explorer home for ${chainMetadata.name}`);
await fetch(baseUrl);
rootLogger.debug(`Explorer home exists for ${chainMetadata.name}`);
if (address) {
rootLogger.debug(
`Checking explorer address page for ${chainMetadata.name}`,
);
const addressUrl = getExplorerAddressUrl(chainMetadata, address);
if (!addressUrl) return false;
rootLogger.debug(`Got address url: ${addressUrl}`);
const addressReq = await fetch(addressUrl);
if (!addressReq.ok && addressReq.status !== 404) return false;
rootLogger.debug(`Explorer address page okay for ${chainMetadata.name}`);
}
if (txHash) {
rootLogger.debug(`Checking explorer tx page for ${chainMetadata.name}`);
const txUrl = getExplorerTxUrl(chainMetadata, txHash);
if (!txUrl) return false;
rootLogger.debug(`Got tx url: ${txUrl}`);
const txReq = await fetch(txUrl);
if (!txReq.ok && txReq.status !== 404) return false;
rootLogger.debug(`Explorer tx page okay for ${chainMetadata.name}`);
}
return true;
}

@ -0,0 +1,85 @@
import { Mailbox__factory } from '@hyperlane-xyz/core';
import { Address, rootLogger } from '@hyperlane-xyz/utils';
import { ChainMetadata } from '../metadata/chainMetadataTypes.js';
import {
CosmJsProvider,
CosmJsWasmProvider,
EthersV5Provider,
ProviderType,
SolanaWeb3Provider,
} from './ProviderType.js';
import { protocolToDefaultProviderBuilder } from './providerBuilders.js';
export async function isRpcHealthy(
metadata: ChainMetadata,
rpcIndex: number,
): Promise<boolean> {
const rpc = metadata.rpcUrls[rpcIndex];
const builder = protocolToDefaultProviderBuilder[metadata.protocol];
const provider = builder([rpc], metadata.chainId);
if (provider.type === ProviderType.EthersV5)
return isEthersV5ProviderHealthy(provider.provider, metadata);
else if (provider.type === ProviderType.SolanaWeb3)
return isSolanaWeb3ProviderHealthy(provider.provider, metadata);
else if (
provider.type === ProviderType.CosmJsWasm ||
provider.type === ProviderType.CosmJs
)
return isCosmJsProviderHealthy(provider.provider, metadata);
else
throw new Error(
`Unsupported provider type ${provider.type}, new health check required`,
);
}
export async function isEthersV5ProviderHealthy(
provider: EthersV5Provider['provider'],
metadata: ChainMetadata,
mailboxAddress?: Address,
): Promise<boolean> {
const chainName = metadata.name;
const blockNumber = await provider.getBlockNumber();
if (!blockNumber || blockNumber < 0) return false;
rootLogger.debug(`Block number is okay for ${chainName}`);
if (mailboxAddress) {
const mailbox = Mailbox__factory.createInterface();
const topics = mailbox.encodeFilterTopics(
mailbox.events['DispatchId(bytes32)'],
[],
);
rootLogger.debug(`Checking mailbox logs for ${chainName}`);
const mailboxLogs = await provider.getLogs({
address: mailboxAddress,
topics,
fromBlock: blockNumber - 99,
toBlock: blockNumber,
});
if (!mailboxLogs) return false;
rootLogger.debug(`Mailbox logs okay for ${chainName}`);
}
return true;
}
export async function isSolanaWeb3ProviderHealthy(
provider: SolanaWeb3Provider['provider'],
metadata: ChainMetadata,
): Promise<boolean> {
const blockNumber = await provider.getBlockHeight();
if (!blockNumber || blockNumber < 0) return false;
rootLogger.debug(`Block number is okay for ${metadata.name}`);
return true;
}
export async function isCosmJsProviderHealthy(
provider: CosmJsProvider['provider'] | CosmJsWasmProvider['provider'],
metadata: ChainMetadata,
): Promise<boolean> {
const readyProvider = await provider;
const blockNumber = await readyProvider.getHeight();
if (!blockNumber || blockNumber < 0) return false;
rootLogger.debug(`Block number is okay for ${metadata.name}`);
return true;
}

@ -55,6 +55,28 @@ export async function runWithTimeout<T>(
}
}
/**
* Executes a fetch request that fails after a timeout via an AbortController.
* @param resource resource to fetch (e.g URL)
* @param options fetch call options object
* @param timeout timeout MS (default 10_000)
* @returns fetch response
*/
export async function fetchWithTimeout(
resource: RequestInfo,
options?: RequestInit,
timeout = 10_000,
) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
const response = await fetch(resource, {
...options,
signal: controller.signal,
});
clearTimeout(id);
return response;
}
/**
* Retries an async function if it raises an exception,
* using exponential backoff.

@ -50,6 +50,7 @@ export {
export { chunk, exclude, randomElement } from './arrays.js';
export {
concurrentMap,
fetchWithTimeout,
pollAsync,
raceWithContext,
retryAsync,
@ -114,10 +115,12 @@ export {
objMap,
objMapEntries,
objMerge,
objOmit,
pick,
promiseObjAll,
stringifyObject,
} from './objects.js';
export { Result, failure, success } from './result.js';
export { difference, setEquality, symmetricDifference } from './sets.js';
export {
errorToString,
@ -156,6 +159,7 @@ export {
TokenCaip19Id,
WithAddress,
} from './types.js';
export { isHttpsUrl } from './url.js';
export { isHttpsUrl, isUrl } from './url.js';
export { assert } from './validation.js';
export { BaseValidator, ValidatorConfig } from './validator.js';
export { tryParseJsonOrYaml } from './yaml.js';

@ -1,6 +1,6 @@
import { expect } from 'chai';
import { deepCopy, deepEquals } from './objects.js';
import { deepCopy, deepEquals, objMerge, objOmit } from './objects.js';
describe('Object utilities', () => {
it('deepEquals', () => {
@ -13,4 +13,51 @@ describe('Object utilities', () => {
expect(deepCopy({ a: 1, b: 2 })).to.eql({ a: 1, b: 2 });
expect(deepCopy({ a: 1, b: 2 })).to.not.eql({ a: 1, b: 3 });
});
it('objMerge', () => {
const obj1 = { a: 1, b: 2, c: { d: '4' } };
const obj2 = { b: 3, c: { d: '5' } };
const merged = objMerge(obj1, obj2);
expect(merged).to.eql({ a: 1, b: 3, c: { d: '5' } });
});
it('objMerge with array', () => {
const obj1 = { a: 1, b: { c: ['arr1'] } };
const obj2 = { a: 2, b: { c: ['arr2'] } };
const merged = objMerge(obj1, obj2, 10, true);
expect(merged).to.eql({ a: 2, b: { c: ['arr2', 'arr1'] } });
});
it('objMerge without array', () => {
const obj1 = { a: 1, b: { c: ['arr1'] } };
const obj2 = { a: 2, b: { c: ['arr2'] } };
const merged = objMerge(obj1, obj2, 10, false);
expect(merged).to.eql({ a: 2, b: { c: ['arr2'] } });
});
it('objOmit', () => {
const obj1 = { a: 1, b: { c: ['arr1'], d: 'string' } };
const obj2 = { a: true, b: { c: true } };
const omitted = objOmit(obj1, obj2);
expect(omitted).to.eql({ b: { d: 'string' } });
});
it('objOmit with array', () => {
const obj1 = { a: 1, b: { c: ['arr1', 'arr2'], d: 'string' } };
const obj2 = { b: { c: ['arr1'] } };
const omitted1_2 = objOmit(obj1, obj2, 10, true);
expect(omitted1_2).to.eql({ a: 1, b: { c: ['arr2'], d: 'string' } });
const obj3 = { a: [{ b: 1 }], c: 2 };
const obj4 = { a: [{ b: 1 }] };
const omitted3_4 = objOmit(obj3, obj4, 10, true);
expect(omitted3_4).to.eql({ a: [], c: 2 });
});
it('objOmit without array', () => {
const obj1 = { a: 1, b: { c: ['arr1', 'arr2'], d: 'string' } };
const obj2 = { b: { c: ['arr1'] } };
const omitted1_2 = objOmit(obj1, obj2, 10, false);
expect(omitted1_2).to.eql({ a: 1, b: { d: 'string' } });
});
});

@ -98,34 +98,87 @@ export function pick<K extends string, V = any>(obj: Record<K, V>, keys: K[]) {
return ret as Record<K, V>;
}
// Recursively merges b into a
// Where there are conflicts, b takes priority over a
export function objMerge(
/**
* Returns a new object that recursively merges b into a
* Where there are conflicts, b takes priority over a
* @param a - The first object
* @param b - The second object
* @param max_depth - The maximum depth to recurse
* @param mergeArrays - If true, arrays will be concatenated instead of replaced
*/
export function objMerge<T = any>(
a: Record<string, any>,
b: Record<string, any>,
max_depth = 10,
): any {
mergeArrays = false,
): T {
if (max_depth === 0) {
throw new Error('objMerge tried to go too deep');
}
if (isObject(a) && isObject(b)) {
const ret: Record<string, any> = {};
const aKeys = new Set(Object.keys(a));
const bKeys = new Set(Object.keys(b));
const allKeys = new Set([...aKeys, ...bKeys]);
for (const key of allKeys.values()) {
if (aKeys.has(key) && bKeys.has(key)) {
ret[key] = objMerge(a[key], b[key], max_depth - 1);
} else if (aKeys.has(key)) {
ret[key] = a[key];
if (!isObject(a) || !isObject(b)) {
return (b ? b : a) as T;
}
const ret: Record<string, any> = {};
const aKeys = new Set(Object.keys(a));
const bKeys = new Set(Object.keys(b));
const allKeys = new Set([...aKeys, ...bKeys]);
for (const key of allKeys.values()) {
if (aKeys.has(key) && bKeys.has(key)) {
if (mergeArrays && Array.isArray(a[key]) && Array.isArray(b[key])) {
ret[key] = [...b[key], ...a[key]];
} else {
ret[key] = b[key];
ret[key] = objMerge(a[key], b[key], max_depth - 1, mergeArrays);
}
} else if (aKeys.has(key)) {
ret[key] = a[key];
} else {
ret[key] = b[key];
}
}
return ret as T;
}
/**
* Return a new object with the fields in b removed from a
* @param a Base object to remove fields from
* @param b The partial object to remove from the base object
* @param max_depth The maximum depth to recurse
* @param sliceArrays If true, arrays will have values sliced out instead of being removed entirely
*/
export function objOmit<T extends Record<string, any> = any>(
a: Record<string, any>,
b: Record<string, any>,
max_depth = 10,
sliceArrays = false,
): T {
if (max_depth === 0) {
throw new Error('objSlice tried to go too deep');
}
if (!isObject(a) || !isObject(b)) {
return a as T;
}
const ret: Record<string, any> = {};
const aKeys = new Set(Object.keys(a));
const bKeys = new Set(Object.keys(b));
for (const key of aKeys.values()) {
if (bKeys.has(key)) {
if (sliceArrays && Array.isArray(a[key]) && Array.isArray(b[key])) {
ret[key] = a[key].filter(
(v: any) => !b[key].some((bv: any) => deepEquals(v, bv)),
);
} else if (isObject(a[key]) && isObject(b[key])) {
const sliced = objOmit(a[key], b[key], max_depth - 1, sliceArrays);
if (Object.keys(sliced).length > 0) {
ret[key] = sliced;
}
} else if (!!b[key] == false) {
ret[key] = objOmit(a[key], b[key], max_depth - 1, sliceArrays);
}
} else {
ret[key] = a[key];
}
return ret;
} else {
return b ? b : a;
}
return ret as T;
}
export function invertKeysAndValues(data: any) {

@ -0,0 +1,18 @@
/********* RESULT MONAD *********/
export type Result<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: string;
};
export function success<T>(data: T): Result<T> {
return { success: true, data };
}
export function failure<T>(error: string): Result<T> {
return { success: false, error };
}

@ -1,3 +1,12 @@
export function isUrl(value: string) {
try {
const url = new URL(value);
return !!url.hostname;
} catch (error) {
return false;
}
}
export function isHttpsUrl(value: string) {
try {
const url = new URL(value);

@ -0,0 +1,17 @@
import { parse as yamlParse } from 'yaml';
import { rootLogger } from './logging.js';
import { Result, failure, success } from './result.js';
export function tryParseJsonOrYaml<T = any>(input: string): Result<T> {
try {
if (input.startsWith('{')) {
return success(JSON.parse(input));
} else {
return success(yamlParse(input));
}
} catch (error) {
rootLogger.error('Error parsing JSON or YAML', error);
return failure('Input is not valid JSON or YAML');
}
}

@ -1,4 +1,5 @@
import type { StorybookConfig } from '@storybook/react-vite';
import { mergeConfig } from 'vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
@ -15,5 +16,10 @@ const config: StorybookConfig = {
docs: {
autodocs: true,
},
async viteFinal(config, { configType }) {
return mergeConfig(config, {
define: { 'process.env': {} },
});
},
};
export default config;

@ -7,10 +7,14 @@
"react-dom": "^18"
},
"dependencies": {
"@hyperlane-xyz/registry": "4.3.6",
"@hyperlane-xyz/sdk": "5.4.0"
"@headlessui/react": "^2.1.8",
"@hyperlane-xyz/sdk": "5.4.0",
"@hyperlane-xyz/utils": "5.4.0",
"clsx": "^2.1.1",
"react-tooltip": "^5.28.0"
},
"devDependencies": {
"@hyperlane-xyz/registry": "4.3.6",
"@storybook/addon-essentials": "^7.6.14",
"@storybook/addon-interactions": "^7.6.14",
"@storybook/addon-links": "^7.6.14",
@ -34,7 +38,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^7.6.14",
"tailwindcss": "^3.2.4",
"tailwindcss": "^3.4.13",
"ts-node": "^10.8.0",
"typescript": "5.3.3",
"vite": "^5.1.1"

@ -0,0 +1,189 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry';
import {
ChainMap,
ChainMetadata,
ChainMetadataSchema,
MultiProtocolProvider,
} from '@hyperlane-xyz/sdk';
import {
Result,
failure,
success,
tryParseJsonOrYaml,
} from '@hyperlane-xyz/utils';
import { ColorPalette } from '../color.js';
import { Button } from '../components/Button.js';
import { CopyButton } from '../components/CopyButton.js';
import { LinkButton } from '../components/LinkButton.js';
import { ChevronIcon } from '../icons/Chevron.js';
import { PlusIcon } from '../icons/Plus.js';
export interface ChainAddMenuProps {
chainMetadata: ChainMap<ChainMetadata>;
overrideChainMetadata?: ChainMap<Partial<ChainMetadata> | undefined>;
onChangeOverrideMetadata: (
overrides?: ChainMap<Partial<ChainMetadata> | undefined>,
) => void;
onClickBack?: () => void;
}
export function ChainAddMenu(props: ChainAddMenuProps) {
return (
<div className="htw-space-y-4">
<Header {...props} />
<Form {...props} />
</div>
);
}
function Header({ onClickBack }: Pick<ChainAddMenuProps, 'onClickBack'>) {
return (
<div>
{!!onClickBack && (
<LinkButton onClick={onClickBack} className="htw-py-1 htw-mb-1.5">
<div className="htw-flex htw-items-center htw-gap-1.5">
<ChevronIcon
width={12}
height={12}
direction="w"
className="htw-opacity-70"
/>
<span className="htw-text-xs htw-text-gray-600">Back</span>
</div>
</LinkButton>
)}
<h2 className="htw-text-lg htw-font-medium">Add chain metadata</h2>
<p className="htw-mt-1 htw-text-sm htw-text-gray-500">
Add metadata for chains not yet included in the{' '}
<a
href={DEFAULT_GITHUB_REGISTRY}
target="_blank"
rel="noopener noreferrer"
className="htw-underline htw-underline-offset-2"
>
Hyperlane Canonical Registry
</a>
. Note, this data will only be used locally in your own browser. It does
not affect the registry.
</p>
</div>
);
}
function Form({
chainMetadata,
overrideChainMetadata,
onChangeOverrideMetadata,
onClickBack,
}: ChainAddMenuProps) {
const [textInput, setTextInput] = useState('');
const [error, setError] = useState<any>(null);
const onChangeInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setTextInput(e.target.value);
setError(null);
};
const onClickAdd = () => {
const result = tryParseMetadataInput(textInput, chainMetadata);
if (result.success) {
onChangeOverrideMetadata({
...overrideChainMetadata,
[result.data.name]: result.data,
});
setTextInput('');
onClickBack?.();
} else {
setError(`Invalid config: ${result.error}`);
}
};
return (
<div className="htw-space-y-1.5">
<div className="htw-relative">
<textarea
className={clsx(
'htw-text-xs htw-resize htw-border htw-border-gray-200 focus:htw-border-gray-400 htw-rounded-sm htw-p-2 htw-w-full htw-min-h-72 htw-outline-none',
error && 'htw-border-red-500',
)}
placeholder={placeholderText}
value={textInput}
onChange={onChangeInput}
></textarea>
{error && <div className="htw-text-red-600 htw-text-sm">{error}</div>}
<CopyButton
copyValue={textInput || placeholderText}
width={14}
height={14}
className="htw-absolute htw-right-6 htw-top-3"
/>
</div>
<Button
onClick={onClickAdd}
className="htw-bg-gray-600 htw-px-3 htw-py-1.5 htw-gap-1 htw-text-white htw-text-sm"
>
<PlusIcon width={20} height={20} color={ColorPalette.White} />
<span>Add chain</span>
</Button>
</div>
);
}
function tryParseMetadataInput(
input: string,
existingChainMetadata: ChainMap<ChainMetadata>,
): Result<ChainMetadata> {
const parsed = tryParseJsonOrYaml(input);
if (!parsed.success) return parsed;
const result = ChainMetadataSchema.safeParse(parsed.data);
if (!result.success) {
console.error('Error validating chain config', result.error);
const firstIssue = result.error.issues[0];
return failure(`${firstIssue.path} => ${firstIssue.message}`);
}
const newMetadata = result.data as ChainMetadata;
const multiProvider = new MultiProtocolProvider(existingChainMetadata);
if (multiProvider.tryGetChainMetadata(newMetadata.name)) {
return failure('name is already in use by another chain');
}
if (multiProvider.tryGetChainMetadata(newMetadata.chainId)) {
return failure('chainId is already in use by another chain');
}
if (
newMetadata.domainId &&
multiProvider.tryGetChainMetadata(newMetadata.domainId)
) {
return failure('domainId is already in use by another chain');
}
return success(newMetadata);
}
const placeholderText = `# YAML data
---
chainId: 11155111
name: sepolia
displayName: Sepolia
protocol: ethereum
rpcUrls:
- http: https://foobar.com
blockExplorers:
- name: Sepolia Etherscan
family: etherscan
url: https://sepolia.etherscan.io
apiUrl: https://api-sepolia.etherscan.io/api
apiKey: '12345'
blocks:
confirmations: 1
estimateBlockTime: 13
`;

@ -0,0 +1,505 @@
import clsx from 'clsx';
import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react';
import { stringify as yamlStringify } from 'yaml';
import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry';
import {
ChainMetadata,
isValidChainMetadata,
mergeChainMetadata,
} from '@hyperlane-xyz/sdk';
import {
Result,
failure,
isNullish,
isUrl,
objMerge,
objOmit,
success,
tryParseJsonOrYaml,
} from '@hyperlane-xyz/utils';
import { ColorPalette } from '../color.js';
import { CopyButton } from '../components/CopyButton.js';
import { IconButton } from '../components/IconButton.js';
import { LinkButton } from '../components/LinkButton.js';
import { TextInput } from '../components/TextInput.js';
import { Tooltip } from '../components/Tooltip.js';
import { BoxArrowIcon } from '../icons/BoxArrow.js';
import { CheckmarkIcon } from '../icons/Checkmark.js';
import { ChevronIcon } from '../icons/Chevron.js';
import { Circle } from '../icons/Circle.js';
import { PlusCircleIcon } from '../icons/PlusCircle.js';
import { Spinner } from '../icons/Spinner.js';
import { XIcon } from '../icons/X.js';
import { useConnectionHealthTest } from '../utils/useChainConnectionTest.js';
import { ChainLogo } from './ChainLogo.js';
import { ChainConnectionType } from './types.js';
export interface ChainDetailsMenuProps {
chainMetadata: ChainMetadata;
overrideChainMetadata?: Partial<ChainMetadata>;
onChangeOverrideMetadata: (overrides?: Partial<ChainMetadata>) => void;
onClickBack?: () => void;
onRemoveChain?: () => void;
}
export function ChainDetailsMenu(props: ChainDetailsMenuProps) {
const mergedMetadata = useMemo(
() =>
mergeChainMetadata(
props.chainMetadata || {},
props.overrideChainMetadata || {},
),
[props],
);
return (
<div className="htw-space-y-4">
<ChainHeader {...props} chainMetadata={mergedMetadata} />
<ButtonRow {...props} chainMetadata={mergedMetadata} />
<ChainRpcs {...props} chainMetadata={mergedMetadata} />
<ChainExplorers {...props} chainMetadata={mergedMetadata} />
<ChainInfo {...props} chainMetadata={mergedMetadata} />
<MetadataOverride {...props} chainMetadata={mergedMetadata} />
</div>
);
}
function ChainHeader({
chainMetadata,
onClickBack,
}: Pick<ChainDetailsMenuProps, 'chainMetadata' | 'onClickBack'>) {
return (
<div>
{!!onClickBack && (
<LinkButton onClick={onClickBack} className="htw-py-1 htw-mb-1.5">
<div className="htw-flex htw-items-center htw-gap-1.5">
<ChevronIcon
width={12}
height={12}
direction="w"
className="htw-opacity-70"
/>
<span className="htw-text-xs htw-text-gray-600">Back</span>
</div>
</LinkButton>
)}
<div className="htw-flex htw-items-center htw-gap-3">
<ChainLogo
chainName={chainMetadata.name}
logoUri={chainMetadata.logoURI}
size={30}
/>
<h2 className="htw-text-lg htw-font-medium">{`${chainMetadata.displayName} Metadata`}</h2>
</div>
</div>
);
}
function ButtonRow({ chainMetadata, onRemoveChain }: ChainDetailsMenuProps) {
const { name } = chainMetadata;
const copyValue = useMemo(
() => yamlStringify(chainMetadata),
[chainMetadata],
);
return (
<div className="htw-pl-0.5 htw-flex htw-items-center htw-gap-10">
<div className="htw-flex htw-items-center htw-gap-1.5">
<BoxArrowIcon width={13} height={13} />
<a
// TODO support alternative registries here
href={`${DEFAULT_GITHUB_REGISTRY}/tree/main/chains/${name}`}
target="_blank"
rel="noopener noreferrer"
className="htw-text-sm hover:htw-underline htw-underline-offset-2 active:htw-opacity-70 htw-transition-all"
>
View in registry
</a>
</div>
<div className="htw-flex htw-items-center htw-gap-1">
<CopyButton
width={12}
height={12}
copyValue={copyValue}
className="htw-text-sm hover:htw-underline htw-underline-offset-2 active:htw-opacity-70"
>
Copy Metadata
</CopyButton>
</div>
{onRemoveChain && (
<LinkButton
onClick={onRemoveChain}
className="htw-text-sm htw-text-red-500 htw-gap-1.5"
>
<XIcon width={10} height={10} color={ColorPalette.Red} />
<span>Delete Chain</span>
</LinkButton>
)}
</div>
);
}
function ChainRpcs(props: ChainDetailsMenuProps) {
return (
<ConnectionsSection
{...props}
header="Connections"
type={ChainConnectionType.RPC}
tooltip="Hyperlane tools require chain metadata<br/>with at least one healthy RPC connection."
/>
);
}
function ChainExplorers(props: ChainDetailsMenuProps) {
return (
<ConnectionsSection
{...props}
header="Block Explorers"
type={ChainConnectionType.Explorer}
tooltip="Explorers are used to provide transaction links and to query data."
/>
);
}
function ConnectionsSection({
chainMetadata,
overrideChainMetadata,
onChangeOverrideMetadata,
header,
type,
tooltip,
}: ChainDetailsMenuProps & {
header: string;
type: ChainConnectionType;
tooltip?: string;
}) {
const values = getConnectionValues(chainMetadata, type);
return (
<div className="htw-space-y-1.5">
<SectionHeader tooltip={tooltip}>{header}</SectionHeader>
{values.map((_, i) => (
<ConnectionRow
key={i}
chainMetadata={chainMetadata}
overrideChainMetadata={overrideChainMetadata}
onChangeOverrideMetadata={onChangeOverrideMetadata}
index={i}
type={type}
/>
))}
<AddConnectionButton
chainMetadata={chainMetadata}
overrideChainMetadata={overrideChainMetadata}
onChangeOverrideMetadata={onChangeOverrideMetadata}
type={type}
/>
</div>
);
}
function AddConnectionButton({
chainMetadata,
overrideChainMetadata,
onChangeOverrideMetadata,
type,
}: ChainDetailsMenuProps & {
type: ChainConnectionType;
}) {
const [isAdding, setIsAdding] = useState(false);
const [isInvalid, setIsInvalid] = useState(false);
const [url, setUrl] = useState('');
const onClickDismiss = () => {
setIsAdding(false);
setIsInvalid(false);
setUrl('');
};
const onClickAdd = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
const currentValues = getConnectionValues(chainMetadata, type);
const newValue = url?.trim();
if (!newValue || !isUrl(newValue) || currentValues.includes(newValue)) {
setIsInvalid(true);
return;
}
let newOverrides: Partial<ChainMetadata> = {};
if (type === ChainConnectionType.RPC) {
newOverrides = {
rpcUrls: [{ http: newValue }],
};
} else if (type === ChainConnectionType.Explorer) {
const hostName = new URL(newValue).hostname;
newOverrides = {
blockExplorers: [{ url: newValue, apiUrl: newValue, name: hostName }],
};
}
onChangeOverrideMetadata(
objMerge<Partial<ChainMetadata>>(
overrideChainMetadata || {},
newOverrides,
10,
true,
),
);
onClickDismiss();
};
if (!isAdding) {
return (
<LinkButton className="htw-gap-3" onClick={() => setIsAdding(true)}>
<PlusCircleIcon width={15} height={15} color={ColorPalette.LightGray} />
<div className="htw-text-sm">{`Add new ${type}`}</div>
</LinkButton>
);
}
return (
<form
className="htw-flex htw-items-center htw-gap-2"
onSubmit={(e) => onClickAdd(e)}
>
<PlusCircleIcon width={15} height={15} color={ColorPalette.LightGray} />
<div className="htw-flex htw-items-stretch htw-gap-1">
<TextInput
className={`htw-w-64 htw-text-sm htw-px-1 htw-rounded-sm ${
isInvalid && 'htw-text-red-500'
}`}
placeholder={`Enter ${type} URL`}
value={url}
onChange={setUrl}
/>
<IconButton
onClick={() => onClickAdd()}
className="htw-bg-gray-600 htw-rounded-sm htw-px-1"
>
<CheckmarkIcon width={20} height={20} color={ColorPalette.White} />
</IconButton>
<IconButton
onClick={onClickDismiss}
className="htw-bg-gray-600 htw-rounded-sm htw-px-1"
>
<XIcon width={9} height={9} color={ColorPalette.White} />
</IconButton>
</div>
</form>
);
}
function ChainInfo({ chainMetadata }: { chainMetadata: ChainMetadata }) {
const { chainId, domainId, deployer, isTestnet } = chainMetadata;
return (
<div className="htw-space-y-1.5">
<SectionHeader>Chain Information</SectionHeader>
<div className="htw-grid htw-grid-cols-2 htw-gap-1.5">
<div>
<SectionHeader className="htw-text-xs">Chain Id</SectionHeader>
<span className="htw-text-sm">{chainId}</span>
</div>
<div>
<SectionHeader className="htw-text-xs">Domain Id</SectionHeader>
<span className="htw-text-sm">{domainId}</span>
</div>
<div>
<SectionHeader className="htw-text-xs">
Contract Deployer
</SectionHeader>
<a
href={deployer?.url}
target="_blank"
rel="noopener noreferrer"
className="htw-text-sm hover:htw-underline htw-underline-offset-2"
>
{deployer?.name || 'Unknown'}
</a>
</div>
<div>
<SectionHeader className="htw-text-xs">Chain Type</SectionHeader>
<span className="htw-text-sm">
{isTestnet ? 'Testnet' : 'Mainnet'}
</span>
</div>
</div>
</div>
);
}
function MetadataOverride({
chainMetadata,
overrideChainMetadata,
onChangeOverrideMetadata,
}: ChainDetailsMenuProps) {
const stringified = overrideChainMetadata
? yamlStringify(overrideChainMetadata)
: '';
const [overrideInput, setOverrideInput] = useState(stringified);
const showButton = overrideInput !== stringified;
const [isInvalid, setIsInvalid] = useState(false);
// Keep input in sync with external changes
useEffect(() => {
setOverrideInput(stringified);
}, [stringified]);
const onChangeInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setOverrideInput(e.target.value);
setIsInvalid(false);
};
const onClickSetOverride = () => {
const trimmed = overrideInput?.trim();
if (!trimmed) {
onChangeOverrideMetadata(undefined);
return;
}
const result = tryParseInput(trimmed, chainMetadata);
if (result.success) {
onChangeOverrideMetadata(result.data);
setOverrideInput(trimmed);
setIsInvalid(false);
} else {
setIsInvalid(true);
}
};
return (
<div className="htw-space-y-1.5">
<SectionHeader tooltip="You can set data here to locally override the metadata from the registry.">
Metadata Overrides
</SectionHeader>
<div className="htw-relative">
<textarea
className={clsx(
'htw-text-xs htw-resize htw-border htw-border-gray-200 focus:htw-border-gray-400 htw-rounded-sm htw-p-1.5 htw-w-full htw-h-12 htw-outline-none',
isInvalid && 'htw-border-red-500',
)}
placeholder={`blocks:\n confirmations: 10`}
value={overrideInput}
onChange={onChangeInput}
></textarea>
<IconButton
onClick={onClickSetOverride}
className={clsx(
'htw-right-3.5 htw-top-2 htw-bg-gray-600 htw-rounded-sm htw-px-1',
showButton ? 'htw-absolute' : 'htw-hidden',
)}
>
<CheckmarkIcon width={20} height={20} color={ColorPalette.White} />
</IconButton>
</div>
</div>
);
}
function SectionHeader({
children,
className,
tooltip,
}: PropsWithChildren<{ className?: string; tooltip?: string }>) {
return (
<div className="htw-flex htw-items-center htw-gap-3">
<h3 className={`htw-text-sm htw-text-gray-500 ${className}`}>
{children}
</h3>
{tooltip && <Tooltip id="metadata-help" content={tooltip} />}
</div>
);
}
function ConnectionRow({
chainMetadata,
overrideChainMetadata = {},
onChangeOverrideMetadata,
index,
type,
}: ChainDetailsMenuProps & {
index: number;
type: ChainConnectionType;
}) {
const isHealthy = useConnectionHealthTest(chainMetadata, index, type);
const value = getConnectionValues(chainMetadata, type)[index];
const isRemovable = isOverrideConnection(overrideChainMetadata, type, value);
const onClickRemove = () => {
let toOmit: Partial<ChainMetadata> = {};
if (type === ChainConnectionType.RPC) {
toOmit = {
rpcUrls: [
overrideChainMetadata.rpcUrls!.find((r) => r.http === value)!,
],
};
} else if (type === ChainConnectionType.Explorer) {
toOmit = {
blockExplorers: [
overrideChainMetadata.blockExplorers!.find((r) => r.url === value)!,
],
};
}
onChangeOverrideMetadata(
objOmit<Partial<ChainMetadata>>(overrideChainMetadata, toOmit, 10, true),
);
};
return (
<div className="htw-flex htw-items-center htw-gap-3">
{isNullish(isHealthy) && type == ChainConnectionType.RPC && (
<Spinner width={14} height={14} />
)}
{isNullish(isHealthy) && type == ChainConnectionType.Explorer && (
<Circle size={14} className="htw-bg-gray-400" />
)}
{!isNullish(isHealthy) && (
<Circle
size={14}
className={isHealthy ? 'htw-bg-green-500' : 'htw-bg-red-500'}
/>
)}
<div className="htw-text-sm htw-truncate">{value}</div>
{isRemovable && (
<IconButton
className="htw-bg-gray-600 htw-rounded-sm htw-p-1 htw-mt-0.5"
onClick={onClickRemove}
>
<XIcon width={8} height={8} color={ColorPalette.White} />
</IconButton>
)}
</div>
);
}
function getConnectionValues(
chainMetadata: Partial<ChainMetadata>,
type: ChainConnectionType,
) {
return (
(type === ChainConnectionType.RPC
? chainMetadata.rpcUrls?.map((r) => r.http)
: chainMetadata.blockExplorers?.map((b) => b.url)) || []
);
}
function isOverrideConnection(
overrides: Partial<ChainMetadata> | undefined,
type: ChainConnectionType,
value: string,
) {
return getConnectionValues(overrides || {}, type).includes(value);
}
function tryParseInput(
input: string,
existingChainMetadata: ChainMetadata,
): Result<Partial<ChainMetadata>> {
const parsed = tryParseJsonOrYaml<Partial<ChainMetadata>>(input);
if (!parsed.success) return parsed;
const merged = mergeChainMetadata(existingChainMetadata, parsed.data);
const isValid = isValidChainMetadata(merged);
return isValid ? success(parsed.data) : failure('Invalid metadata overrides');
}

@ -2,8 +2,8 @@ import React, { ReactElement, useEffect, useState } from 'react';
import type { IRegistry } from '@hyperlane-xyz/registry';
import { Circle } from './Circle.js';
import { QuestionMarkIcon } from './QuestionMark.js';
import { Circle } from '../icons/Circle.js';
import { QuestionMarkIcon } from '../icons/QuestionMark.js';
type SvgIcon = (props: {
width: number;
@ -13,7 +13,8 @@ type SvgIcon = (props: {
export interface ChainLogoProps {
chainName: string;
registry: IRegistry;
logoUri?: string;
registry?: IRegistry;
size?: number;
background?: boolean;
Icon?: SvgIcon; // Optional override for the logo in the registry
@ -21,6 +22,7 @@ export interface ChainLogoProps {
export function ChainLogo({
chainName,
logoUri,
registry,
size = 32,
background = false,
@ -31,17 +33,18 @@ export function ChainLogo({
const iconSize = Math.floor(size / 1.9);
const [svgLogos, setSvgLogos] = useState({});
const logoUri = svgLogos[chainName];
const uri = logoUri || svgLogos[chainName];
useEffect(() => {
if (!chainName || svgLogos[chainName] || Icon) return;
if (!chainName || svgLogos[chainName] || logoUri || Icon || !registry)
return;
registry
.getChainLogoUri(chainName)
.then((uri) => uri && setSvgLogos({ ...svgLogos, [chainName]: uri }))
.catch((err) => console.error(err));
}, [chainName, registry, svgLogos, Icon]);
if (!logoUri && !Icon) {
if (!uri && !Icon) {
return (
<Circle size={size} title={title} bgColorSeed={bgColorSeed}>
{chainName ? (
@ -55,11 +58,11 @@ export function ChainLogo({
if (background) {
return (
<Circle size={size} title={title} classes="htw-bg-gray-100">
<Circle size={size} title={title} className="htw-bg-gray-100">
{Icon ? (
<Icon width={iconSize} height={iconSize} title={title} />
) : (
<img src={logoUri} alt={title} width={iconSize} height={iconSize} />
<img src={uri} alt={title} width={iconSize} height={iconSize} />
)}
</Circle>
);
@ -67,7 +70,7 @@ export function ChainLogo({
return Icon ? (
<Icon width={size} height={size} title={title} />
) : (
<img src={logoUri} alt={title} width={size} height={size} />
<img src={uri} alt={title} width={size} height={size} />
);
}
}

@ -0,0 +1,322 @@
import React, { useCallback, useMemo } from 'react';
import {
ChainMap,
ChainMetadata,
ChainName,
mergeChainMetadataMap,
} from '@hyperlane-xyz/sdk';
import { ProtocolType } from '@hyperlane-xyz/utils';
import {
SearchMenu,
SortOrderOption,
SortState,
} from '../components/SearchMenu.js';
import { SegmentedControl } from '../components/SegmentedControl.js';
import { ChainAddMenu } from './ChainAddMenu.js';
import { ChainDetailsMenu } from './ChainDetailsMenu.js';
import { ChainLogo } from './ChainLogo.js';
enum ChainSortByOption {
Name = 'name',
ChainId = 'chain id',
Protocol = 'protocol',
}
enum FilterTestnetOption {
Testnet = 'testnet',
Mainnet = 'mainnet',
}
interface ChainFilterState {
type?: FilterTestnetOption;
protocol?: ProtocolType;
}
const defaultFilterState: ChainFilterState = {
type: undefined,
protocol: undefined,
};
interface CustomListItemField {
header: string;
data: ChainMap<{ display: string; sortValue: number }>;
}
export interface ChainSearchMenuProps {
chainMetadata: ChainMap<ChainMetadata>;
overrideChainMetadata?: ChainMap<Partial<ChainMetadata> | undefined>;
onChangeOverrideMetadata: (
overrides?: ChainMap<Partial<ChainMetadata> | undefined>,
) => void;
onClickChain: (chain: ChainMetadata) => void;
// Replace the default 2nd column (deployer) with custom data
customListItemField?: CustomListItemField;
// Auto-navigate to a chain details menu
showChainDetails?: ChainName;
// Auto-navigate to a chain add menu
showAddChainMenu?: boolean;
// Include add button above list
showAddChainButton?: boolean;
}
export function ChainSearchMenu({
chainMetadata,
onChangeOverrideMetadata,
overrideChainMetadata,
onClickChain,
customListItemField,
showChainDetails,
showAddChainButton,
showAddChainMenu,
}: ChainSearchMenuProps) {
const [drilldownChain, setDrilldownChain] = React.useState<
ChainName | undefined
>(showChainDetails);
const [addChain, setAddChain] = React.useState(showAddChainMenu || false);
const { listData, mergedMetadata } = useMemo(() => {
const mergedMetadata = mergeChainMetadataMap(
chainMetadata,
overrideChainMetadata,
);
return { mergedMetadata, listData: Object.values(mergedMetadata) };
}, [chainMetadata]);
const { ListComponent, searchFn, sortOptions, defaultSortState } =
useCustomizedListItems(customListItemField);
if (drilldownChain && mergedMetadata[drilldownChain]) {
const isLocalOverrideChain = !chainMetadata[drilldownChain];
const onRemoveChain = () => {
const newOverrides = { ...overrideChainMetadata };
delete newOverrides[drilldownChain];
onChangeOverrideMetadata(newOverrides);
};
return (
<ChainDetailsMenu
chainMetadata={chainMetadata[drilldownChain]}
overrideChainMetadata={overrideChainMetadata?.[drilldownChain]}
onChangeOverrideMetadata={(o) =>
onChangeOverrideMetadata({
...overrideChainMetadata,
[drilldownChain]: o,
})
}
onClickBack={() => setDrilldownChain(undefined)}
onRemoveChain={isLocalOverrideChain ? onRemoveChain : undefined}
/>
);
}
if (addChain) {
return (
<ChainAddMenu
chainMetadata={chainMetadata}
overrideChainMetadata={overrideChainMetadata}
onChangeOverrideMetadata={onChangeOverrideMetadata}
onClickBack={() => setAddChain(false)}
/>
);
}
return (
<SearchMenu<
ChainMetadata<{ disabled?: boolean }>,
ChainSortByOption,
ChainFilterState
>
data={listData}
ListComponent={ListComponent}
searchFn={searchFn}
onClickItem={onClickChain}
onClickEditItem={(chain) => setDrilldownChain(chain.name)}
sortOptions={sortOptions}
defaultSortState={defaultSortState}
FilterComponent={ChainFilters}
defaultFilterState={defaultFilterState}
placeholder="Chain Name or ID"
onClickAddItem={showAddChainButton ? () => setAddChain(true) : undefined}
/>
);
}
function ChainListItem({
data: chain,
customField,
}: {
data: ChainMetadata;
customField?: CustomListItemField;
}) {
return (
<>
<div className="htw-flex htw-items-center">
<div className="htw-shrink-0">
<ChainLogo chainName={chain.name} logoUri={chain.logoURI} size={32} />
</div>
<div className="htw-ml-3 htw-text-left htw-overflow-hidden">
<div className="htw-text-sm htw-font-medium truncate">
{chain.displayName}
</div>
<div className="htw-text-[0.7rem] htw-text-gray-500">
{chain.isTestnet ? 'Testnet' : 'Mainnet'}
</div>
</div>
</div>
<div className="htw-text-left htw-overflow-hidden">
<div className="htw-text-sm truncate">
{customField
? customField.data[chain.name].display || 'Unknown'
: chain.deployer?.name || 'Unknown deployer'}
</div>
<div className="htw-text-[0.7rem] htw-text-gray-500">
{customField ? customField.header : 'Deployer'}
</div>
</div>
</>
);
}
function ChainFilters({
value,
onChange,
}: {
value: ChainFilterState;
onChange: (s: ChainFilterState) => void;
}) {
return (
<div className="htw-py-3 htw-px-2.5 htw-space-y-4">
<div className="htw-flex htw-flex-col htw-items-start htw-gap-2">
<label className="htw-text-sm htw-text-gray-600 htw-pl-px">Type</label>
<SegmentedControl
options={Object.values(FilterTestnetOption)}
onChange={(selected) => onChange({ ...value, type: selected })}
allowEmpty
/>
</div>
<div className="htw-flex htw-flex-col htw-items-start htw-gap-2">
<label className="htw-text-sm htw-text-gray-600 htw-pl-px">
Protocol
</label>
<SegmentedControl
options={Object.values(ProtocolType)}
onChange={(selected) => onChange({ ...value, protocol: selected })}
allowEmpty
/>
</div>
</div>
);
}
function chainSearch({
data,
query,
sort,
filter,
customListItemField,
}: {
data: ChainMetadata[];
query: string;
sort: SortState<ChainSortByOption>;
filter: ChainFilterState;
customListItemField?: CustomListItemField;
}) {
const queryFormatted = query.trim().toLowerCase();
return (
data
// Query search
.filter(
(chain) =>
chain.name.includes(queryFormatted) ||
chain.displayName?.toLowerCase().includes(queryFormatted) ||
chain.chainId.toString().includes(queryFormatted) ||
chain.domainId?.toString().includes(queryFormatted),
)
// Filter options
.filter((chain) => {
let included = true;
if (filter.type) {
included &&=
!!chain.isTestnet === (filter.type === FilterTestnetOption.Testnet);
}
if (filter.protocol) {
included &&= chain.protocol === filter.protocol;
}
return included;
})
// Sort options
.sort((c1, c2) => {
// Special case handling for if the chains are being sorted by the
// custom field provided to ChainSearchMenu
if (customListItemField && sort.sortBy === customListItemField.header) {
const result =
customListItemField.data[c1.name].sortValue -
customListItemField.data[c2.name].sortValue;
return sort.sortOrder === SortOrderOption.Asc ? result : -result;
}
// Otherwise sort by the default options
let sortValue1 = c1.name;
let sortValue2 = c2.name;
if (sort.sortBy === ChainSortByOption.ChainId) {
sortValue1 = c1.chainId.toString();
sortValue2 = c2.chainId.toString();
} else if (sort.sortBy === ChainSortByOption.Protocol) {
sortValue1 = c1.protocol;
sortValue2 = c2.protocol;
}
return sort.sortOrder === SortOrderOption.Asc
? sortValue1.localeCompare(sortValue2)
: sortValue2.localeCompare(sortValue1);
})
);
}
/**
* This hook creates closures around the provided customListItemField data
* This is useful because SearchMenu will do handle the list item rendering and
* management but the custom data is more or a chain-search-specific concern
*/
function useCustomizedListItems(customListItemField) {
// Create closure of ChainListItem but with customField pre-bound
const ListComponent = useCallback(
({ data }: { data: ChainMetadata<{ disabled?: boolean }> }) => (
<ChainListItem data={data} customField={customListItemField} />
),
[ChainListItem, customListItemField],
);
// Bind the custom field to the search function
const searchFn = useCallback(
(args: Parameters<typeof chainSearch>[0]) =>
chainSearch({ ...args, customListItemField }),
[customListItemField],
);
// Merge the custom field into the sort options if a custom field exists
const sortOptions = useMemo(
() => [
...(customListItemField ? [customListItemField.header] : []),
...Object.values(ChainSortByOption),
],
[customListItemField],
) as ChainSortByOption[];
// Sort by the custom field by default, if one is provided
const defaultSortState = useMemo(
() =>
customListItemField
? {
sortBy: customListItemField.header,
sortOrder: SortOrderOption.Desc,
}
: undefined,
[customListItemField],
) as SortState<ChainSortByOption> | undefined;
return { ListComponent, searchFn, sortOptions, defaultSortState };
}

@ -0,0 +1,4 @@
export enum ChainConnectionType {
RPC = 'rpc',
Explorer = 'explorer',
}

@ -1,10 +1,11 @@
export enum ColorPalette {
Black = '#010101',
White = '#FFFFFF',
Blue = '#2362C0',
Blue = '#2764C1',
DarkBlue = '#162A4A',
LightBlue = '#82A8E4',
Pink = '#CF2FB3',
LightGray = '#D3D4D7',
Gray = '#6B7280',
Beige = '#F1EDE9',
Red = '#BF1B15',

@ -0,0 +1,21 @@
import clsx from 'clsx';
import React, { ButtonHTMLAttributes, PropsWithChildren } from 'react';
type Props = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>;
export function Button(props: Props) {
const { className, children, ...rest } = props;
const base =
'htw-flex htw-items-center htw-justify-center htw-rounded-sm htw-transition-all';
const onHover = 'hover:htw-opacity-80';
const onDisabled = 'disabled:htw-opacity-30 disabled:htw-cursor-default';
const onActive = 'active:htw-scale-95';
const allClasses = clsx(base, onHover, onDisabled, onActive, className);
return (
<button type="button" className={allClasses} {...rest}>
{children}
</button>
);
}

@ -0,0 +1,51 @@
import React, {
ButtonHTMLAttributes,
PropsWithChildren,
useState,
} from 'react';
import { CheckmarkIcon } from '../icons/Checkmark.js';
import { CopyIcon } from '../icons/Copy.js';
import { tryClipboardSet } from '../utils/clipboard.js';
type Props = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & {
width?: number;
height?: number;
copyValue: string;
};
export function CopyButton({
width,
height,
copyValue,
className,
children,
...rest
}: Props) {
const [showCheckmark, setShowCheckmark] = useState(false);
const onClick = async () => {
const result = await tryClipboardSet(copyValue);
if (result) {
setShowCheckmark(true);
setTimeout(() => setShowCheckmark(false), 2000);
}
};
return (
<button
onClick={onClick}
type="button"
title="Copy"
className={`htw-flex htw-items-center htw-justify-center htw-gap-2 htw-transition-all ${className}`}
{...rest}
>
{showCheckmark ? (
<CheckmarkIcon width={width} height={height} />
) : (
<CopyIcon width={width} height={height} />
)}
{children}
</button>
);
}

@ -0,0 +1,24 @@
import clsx from 'clsx';
import React, { ButtonHTMLAttributes, PropsWithChildren } from 'react';
type Props = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & {
width?: number;
height?: number;
};
export function IconButton(props: Props) {
const { className, children, ...rest } = props;
const base =
'htw-flex htw-items-center htw-justify-center htw-transition-all';
const onHover = 'hover:htw-opacity-70 hover:htw-scale-105';
const onDisabled = 'disabled:htw-opacity-30 disabled:htw-cursor-default';
const onActive = 'active:htw-opacity-60';
const allClasses = clsx(base, onHover, onDisabled, onActive, className);
return (
<button type="button" className={allClasses} {...rest}>
{children}
</button>
);
}

@ -0,0 +1,21 @@
import clsx from 'clsx';
import React, { ButtonHTMLAttributes, PropsWithChildren } from 'react';
type Props = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>;
export function LinkButton(props: Props) {
const { className, children, ...rest } = props;
const base =
'htw-flex htw-items-center htw-justify-center htw-transition-all';
const onHover = 'hover:htw-underline htw-underline-offset-2';
const onDisabled = 'disabled:htw-opacity-30 disabled:htw-cursor-default';
const onActive = 'active:htw-opacity-70';
const allClasses = clsx(base, onHover, onDisabled, onActive, className);
return (
<button type="button" className={allClasses} {...rest}>
{children}
</button>
);
}

@ -0,0 +1,344 @@
import clsx from 'clsx';
import React, { ComponentType, useMemo, useState } from 'react';
import { deepEquals, isObject, toTitleCase } from '@hyperlane-xyz/utils';
import { ColorPalette } from '../color.js';
import { ArrowIcon } from '../icons/Arrow.js';
import { PencilIcon } from '../icons/Pencil.js';
import { PlusIcon } from '../icons/Plus.js';
import { SearchIcon } from '../icons/Search.js';
import { XIcon } from '../icons/X.js';
import { DropdownMenu } from '../layout/DropdownMenu.js';
import { Popover } from '../layout/Popover.js';
import { IconButton } from './IconButton.js';
import { InputProps, TextInput } from './TextInput.js';
export interface SearchMenuProps<
ListItemData extends { disabled?: boolean },
SortBy extends string,
FilterState,
> {
// The list of data items to show
data: ListItemData[];
// The component with which the list items will be rendered
ListComponent: ComponentType<{ data: ListItemData }>;
// Handler for list item click event
onClickItem: (item: ListItemData) => void;
// Handler for edit list item click event
onClickEditItem: (item: ListItemData) => void;
// Handler for searching through list item data
searchFn: (args: {
data: ListItemData[];
query: string;
sort: SortState<SortBy>;
filter: FilterState;
}) => ListItemData[];
// List of sort options
sortOptions: SortBy[];
// Default sort state for list data
defaultSortState?: SortState<SortBy>;
// The component with which the filter state will be rendered
FilterComponent: ComponentType<{
value: FilterState;
onChange: (s: FilterState) => void;
}>;
// Default filter state for list data
defaultFilterState: FilterState;
// Placeholder text for the search input
placeholder?: string;
// Handler for add button click event
onClickAddItem?: () => void;
}
export function SearchMenu<
ListItem extends { disabled?: boolean },
SortBy extends string,
FilterState,
>({
data,
ListComponent,
searchFn,
onClickItem,
onClickEditItem,
sortOptions,
defaultSortState,
FilterComponent,
defaultFilterState,
placeholder,
onClickAddItem,
}: SearchMenuProps<ListItem, SortBy, FilterState>) {
const [searchQuery, setSearchQuery] = useState('');
const [isEditMode, setIsEditMode] = useState(false);
const [sortState, setSortState] = useState<SortState<SortBy>>(
defaultSortState || {
sortBy: sortOptions[0],
sortOrder: SortOrderOption.Asc,
},
);
const [filterState, setFilterState] =
useState<FilterState>(defaultFilterState);
const results = useMemo(
() =>
searchFn({
data,
query: searchQuery,
sort: sortState,
filter: filterState,
}),
[data, searchQuery, sortState, filterState, searchFn],
);
return (
<div className="htw-flex htw-flex-col htw-gap-2">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder={placeholder}
/>
<div className="htw-flex htw-items-center htw-justify-between">
<div className="htw-flex htw-items-center htw-gap-5">
<SortDropdown
options={sortOptions}
value={sortState}
onChange={setSortState}
/>
<FilterDropdown
value={filterState}
defaultValue={defaultFilterState}
onChange={setFilterState}
FilterComponent={FilterComponent}
/>
</div>
<div className="htw-flex htw-items-center htw-gap-3 htw-mr-0.5">
<IconButton
onClick={() => setIsEditMode(!isEditMode)}
className="htw-p-1.5 htw-border htw-border-gray-200 htw-rounded-full"
title="Edit items"
>
<PencilIcon
width={14}
height={14}
color={isEditMode ? ColorPalette.Blue : ColorPalette.Black}
/>
</IconButton>
{onClickAddItem && (
<IconButton
onClick={onClickAddItem}
className="htw-p-0.5 htw-border htw-border-gray-200 htw-rounded-full"
title="Add item"
>
<PlusIcon width={22} height={22} />
</IconButton>
)}
</div>
</div>
<div className="htw-flex htw-flex-col htw-divide-y htw-divide-gray-100">
{results.length ? (
results.map((data, i) => (
<ListItem
key={i}
data={data}
isEditMode={isEditMode}
onClickItem={onClickItem}
onClickEditItem={onClickEditItem}
ListComponent={ListComponent}
/>
))
) : (
<div className="htw-my-8 htw-text-gray-500 htw-text-center">
No results found
</div>
)}
</div>
</div>
);
}
function SearchBar(props: InputProps) {
return (
<div className="htw-relative">
<SearchIcon
width={18}
height={18}
className="htw-absolute htw-left-4 htw-top-1/2 -htw-translate-y-1/2 htw-opacity-50"
/>
<TextInput
{...props}
className="htw-w-full htw-rounded-lg htw-px-11 htw-py-3"
/>
</div>
);
}
function SortDropdown<SortBy extends string>({
options,
value,
onChange,
}: {
options: SortBy[];
value: SortState<SortBy>;
onChange: (v: SortState<SortBy>) => void;
}) {
const onToggleOrder = () => {
onChange({
...value,
sortOrder:
value.sortOrder === SortOrderOption.Asc
? SortOrderOption.Desc
: SortOrderOption.Asc,
});
};
const onSetSortBy = (sortBy: SortBy) => {
onChange({
...value,
sortBy,
});
};
return (
<div className="htw-h-7 htw-flex htw-items-stretch htw-text-sm htw-rounded htw-border htw-border-gray-200">
<div className="htw-flex htw-bg-gray-100 htw-px-2">
<span className="htw-place-self-center">Sort</span>
</div>
<DropdownMenu
button={
<span className="htw-place-self-center htw-px-2">
{toTitleCase(value.sortBy)}
</span>
}
buttonClassname="htw-flex htw-items-stretch hover:htw-bg-gray-100 active:htw-scale-95"
menuClassname="htw-py-1.5 htw-px-2 htw-flex htw-flex-col htw-gap-2 htw-text-sm htw-border htw-border-gray-100"
menuItems={options.map((o) => (
<div
className="htw-rounded htw-p-1.5 hover:htw-bg-gray-200"
onClick={() => onSetSortBy(o)}
>
{toTitleCase(o)}
</div>
))}
menuProps={{ anchor: 'bottom start' }}
/>
<IconButton
onClick={onToggleOrder}
className="hover:htw-bg-gray-100 active:htw-scale-95 htw-px-0.5 htw-py-1.5"
title="Toggle sort"
>
<ArrowIcon
direction={value.sortOrder === SortOrderOption.Asc ? 'n' : 's'}
width={14}
height={14}
/>
</IconButton>
</div>
);
}
function FilterDropdown<FilterState>({
value,
defaultValue,
onChange,
FilterComponent,
}: {
value: FilterState;
defaultValue: FilterState;
onChange: (v: FilterState) => void;
FilterComponent: ComponentType<{
value: FilterState;
onChange: (s: FilterState) => void;
}>;
}) {
const filterValues = useMemo(() => {
if (!value || !isObject(value)) return [];
const modifiedKeys = Object.keys(value).filter(
(k) => !deepEquals(value[k], defaultValue[k]),
);
return modifiedKeys.map((k) => value[k]);
}, [value]);
const hasFilters = filterValues.length > 0;
const onClear = () => {
onChange(defaultValue);
};
return (
<div className="htw-h-7 htw-flex htw-items-stretch htw-text-sm htw-rounded htw-border htw-border-gray-200">
<div className="htw-flex htw-bg-gray-100 htw-px-2">
<span className="htw-place-self-center">Filter</span>
</div>
<Popover
button={
<span
className={clsx(
'htw-place-self-center htw-px-3',
!hasFilters && 'htw-text-gray-400',
)}
>
{hasFilters ? filterValues.map(toTitleCase).join(', ') : 'None'}
</span>
}
buttonClassname="htw-h-full htw-flex htw-items-stretch hover:htw-bg-gray-100 active:htw-scale-95"
>
<FilterComponent value={value} onChange={onChange} />
</Popover>
<IconButton
disabled={!filterValues.length}
onClick={onClear}
className="hover:htw-bg-gray-100 active:htw-scale-95 htw-px-1 htw-py-1.5"
title="Clear filters"
>
<XIcon width={9} height={9} />
</IconButton>
</div>
);
}
interface ListItemProps<ListItemData extends { disabled?: boolean }> {
data: ListItemData;
isEditMode: boolean;
onClickItem: (item: ListItemData) => void;
onClickEditItem: (item: ListItemData) => void;
ListComponent: ComponentType<{ data: ListItemData }>;
}
function ListItem<ListItemData extends { disabled?: boolean }>({
data,
isEditMode,
onClickEditItem,
onClickItem,
ListComponent,
}: ListItemProps<ListItemData>) {
return (
<button
className={clsx(
'-htw-mx-2 htw-px-2.5 htw-py-2.5 htw-grid htw-grid-cols-[1fr,1fr,auto] htw-items-center htw-relative htw-rounded htw-transition-all htw-duration-250',
data.disabled
? 'htw-opacity-50'
: 'hover:htw-bg-gray-100 active:htw-scale-95',
)}
type="button"
disabled={data.disabled}
onClick={() => (isEditMode ? onClickEditItem(data) : onClickItem(data))}
>
<ListComponent data={data} />
{isEditMode && (
<div className="htw-justify-self-end">
<PencilIcon width={16} height={16} />
</div>
)}
</button>
);
}
export interface SortState<SortBy> {
sortBy: SortBy;
sortOrder: SortOrderOption;
}
export enum SortOrderOption {
Asc = 'asc',
Desc = 'desc',
}

@ -0,0 +1,51 @@
import React, { useState } from 'react';
import { toTitleCase } from '@hyperlane-xyz/utils';
interface SegmentedControlProps<O extends string> {
options: O[];
onChange: (selected: O | undefined) => void;
allowEmpty?: boolean;
}
export function SegmentedControl<O extends string>({
options,
onChange,
allowEmpty,
}: SegmentedControlProps<O>) {
// State to keep track of the selected option index
const [selectedIndex, setSelectedIndex] = useState<number | undefined>(
allowEmpty ? undefined : 0,
);
const handleSelect = (index: number) => {
// Unselect when the same option is re-clicked
if (selectedIndex === index && allowEmpty) {
setSelectedIndex(undefined);
onChange(undefined);
} else {
setSelectedIndex(index);
onChange(options[index]);
}
};
return (
<div className="htw-inline-flex htw-rounded htw-border htw-border-gray-200 htw-divide-x">
{options.map((option, index) => (
<button
key={index}
onClick={() => handleSelect(index)}
className={`htw-px-2 sm:htw-px-3 htw-py-1 htw-text-sm htw-transition-all htw-duration-200 htw-ease-in-out htw-focus:outline-none first:htw-rounded-l last:htw-rounded-r
${
selectedIndex === index
? 'htw-bg-gray-100 htw-font-medium'
: 'htw-bg-white hover:htw-bg-gray-100'
}
`}
>
{toTitleCase(option!)}
</button>
))}
</div>
);
}

@ -0,0 +1,25 @@
import React, { ChangeEvent, InputHTMLAttributes } from 'react';
export type InputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange'
> & {
onChange?: (v: string) => void;
className?: string;
};
export function TextInput({ onChange, className, ...props }: InputProps) {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (onChange) onChange(e?.target?.value || '');
};
return (
<input
type="text"
autoComplete="off"
onChange={handleChange}
className={`htw-bg-gray-100 focus:htw-bg-gray-200 disabled:htw-bg-gray-500 htw-outline-none htw-transition-all htw-duration-300 ${className}`}
{...props}
/>
);
}

@ -0,0 +1,28 @@
import React, { AnchorHTMLAttributes } from 'react';
import { Tooltip as ReactTooltip } from 'react-tooltip';
import { Circle } from '../icons/Circle.js';
import { QuestionMarkIcon } from '../icons/QuestionMark.js';
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
id: string;
content: string;
size?: number;
};
export function Tooltip({ id, content, size = 16, ...rest }: Props) {
return (
<>
<a data-tooltip-id={id} data-tooltip-html={content} {...rest}>
<Circle size={size} className="htw-bg-gray-200 htw-border-gray-300">
<QuestionMarkIcon
width={size - 2}
height={size - 2}
className="htw-opacity-60"
/>
</Circle>
</a>
<ReactTooltip id={id} />
</>
);
}

@ -2,23 +2,12 @@ import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
import { DefaultIconProps } from './types.js';
// Paper airplane shape
function _AirplaneIcon({ width, height, color, classes }: Props) {
function _AirplaneIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width={width}
height={height}
className={classes}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083l6-15Zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471-.47 1.178Z"
fill={color || ColorPalette.Blue}

@ -0,0 +1,45 @@
import React, { SVGProps, memo } from 'react';
import { ColorPalette } from '../color.js';
type Props = SVGProps<SVGSVGElement> & {
direction: 'n' | 'e' | 's' | 'w';
};
function _ArrowIcon({ fill, className, direction, ...rest }: Props) {
let directionClass;
switch (direction) {
case 'n':
directionClass = 'htw-rotate-180';
break;
case 'e':
directionClass = '-htw-rotate-90';
break;
case 's':
directionClass = '';
break;
case 'w':
directionClass = 'htw-rotate-90';
break;
default:
throw new Error(`Invalid direction ${direction}`);
}
return (
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`${directionClass} ${className}`}
{...rest}
>
<path
fillRule="evenodd"
d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1"
fill={fill || ColorPalette.Black}
/>
</svg>
);
}
export const ArrowIcon = memo(_ArrowIcon);

@ -0,0 +1,24 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _BoxArrowIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
fillRule="evenodd"
d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5"
fill={color || ColorPalette.Black}
/>
<path
fillRule="evenodd"
d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const BoxArrowIcon = memo(_BoxArrowIcon);

@ -0,0 +1,18 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _CheckmarkIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const CheckmarkIcon = memo(_CheckmarkIcon);

@ -0,0 +1,46 @@
import React, { SVGProps, memo } from 'react';
import { ColorPalette } from '../color.js';
type Props = SVGProps<SVGSVGElement> & {
direction: 'n' | 'e' | 's' | 'w';
};
function _ChevronIcon({ color, className, direction, ...rest }: Props) {
let directionClass;
switch (direction) {
case 'n':
directionClass = 'htw-rotate-180';
break;
case 'e':
directionClass = '-htw-rotate-90';
break;
case 's':
directionClass = '';
break;
case 'w':
directionClass = 'htw-rotate-90';
break;
default:
throw new Error(`Invalid direction ${direction}`);
}
return (
<svg
width="4"
height="6"
viewBox="0 0 16 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`${directionClass} ${className}`}
{...rest}
>
<path
d="M15.1 1.4 13.8.1 8 5.9 2.2.2 1 1.6l7.2 7 7-7.2Z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const ChevronIcon = memo(_ChevronIcon);

@ -6,13 +6,13 @@ export function Circle({
size,
title,
bgColorSeed,
classes,
className,
children,
}: PropsWithChildren<{
size: string | number;
title?: string;
bgColorSeed?: number;
classes?: string;
className?: string;
}>) {
const bgColor =
bgColorSeed === null || bgColorSeed == undefined
@ -21,7 +21,7 @@ export function Circle({
return (
<div
style={{ width: `${size}px`, height: `${size}px` }}
className={`htw-flex htw-items-center htw-justify-center htw-rounded-full htw-transition-all overflow-hidden ${bgColor} ${classes}`}
className={`htw-flex htw-items-center htw-justify-center htw-rounded-full htw-transition-all overflow-hidden ${bgColor} ${className}`}
title={title}
>
{children}

@ -0,0 +1,34 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _CopyIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" {...rest}>
<rect
x="1"
y="7"
width="13"
height="13"
rx="1"
stroke={color || ColorPalette.Black}
strokeWidth="2.1"
fill="none"
/>
<rect
x="7"
y="1"
width="13"
height="13"
rx="1"
stroke={color || ColorPalette.Black}
strokeWidth="2.1"
fill="none"
/>
</svg>
);
}
export const CopyIcon = memo(_CopyIcon);

@ -2,23 +2,12 @@ import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
import { DefaultIconProps } from './types.js';
// Envelope with checkmark
function _EnvelopeIcon({ width, height, color, classes }: Props) {
function _EnvelopeIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width={width}
height={height}
className={classes}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
d="M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414.05 3.555ZM0 4.697v7.104l5.803-3.558L0 4.697ZM6.761 8.83l-6.57 4.026A2 2 0 0 0 2 14h6.256A4.493 4.493 0 0 1 8 12.5a4.49 4.49 0 0 1 1.606-3.446l-.367-.225L8 9.586l-1.239-.757ZM16 4.697v4.974A4.491 4.491 0 0 0 12.5 8a4.49 4.49 0 0 0-1.965.45l-.338-.207L16 4.697Z"
fill={color || ColorPalette.Blue}

@ -0,0 +1,18 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _FilterIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 16" {...rest}>
<path
d="M8.55556 16V13.3333H13.4444V16H8.55556ZM3.66667 9.33333V6.66667H18.3333V9.33333H3.66667ZM0 2.66667V0H22V2.66667H0Z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const FilterIcon = memo(_FilterIcon);

@ -0,0 +1,18 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _FunnelIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const FunnelIcon = memo(_FunnelIcon);

@ -0,0 +1,18 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _GearIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 579.2 579.2" {...rest}>
<path
d="M570 353.8h9.2V228.4H497a216 216 0 0 0-17.4-42.4l51.4-51.4 6.5-6.5-6.5-6.5-75.7-75.7-6.5-6.5-6.5 6.5-51.6 51.6a217.9 217.9 0 0 0-40-16.5V0H225.3v81c-13 4-25.8 9.1-38 15.5l-50.6-50.6-6.4-6.5-6.5 6.5L48 121.6l-6.5 6.5 6.5 6.5L97.5 184c-7.3 13.1-13.2 27-17.6 41.2H0v125.5h79A216.1 216.1 0 0 0 97.5 395L48 444.6l-6.5 6.5 6.5 6.5 75.8 75.7 6.5 6.5 6.4-6.5 50.6-50.6c13 6.8 26.8 12.3 41 16.4v80.1h125.5v-81.9c12.8-4 25.1-9.3 37-15.7l51.6 51.7 6.5 6.5 6.5-6.5 75.7-75.7 6.5-6.5-6.5-6.5-51.4-51.5a216.6 216.6 0 0 0 16.5-39.3H570zm-152-64.2a130.1 130.1 0 0 1-260 0 130.1 130.1 0 0 1 260.1 0z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const GearIcon = memo(_GearIcon);

@ -2,22 +2,11 @@ import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
import { DefaultIconProps } from './types.js';
function _LockIcon({ width, height, color, classes }: Props) {
function _LockIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 15 18"
width={width}
height={height}
className={classes}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 18" {...rest}>
<path
d="M7.14 1.13c.76 0 1.49.23 2.02.65.54.43.84 1 .84 1.6v4.5H4.29v-4.5c0-.6.3-1.17.83-1.6a3.29 3.29 0 0 1 2.02-.66Zm4.29 6.75v-4.5c0-.9-.45-1.76-1.26-2.4C9.37.37 8.28 0 7.14 0 6.01 0 4.92.36 4.11.99c-.8.63-1.25 1.49-1.25 2.38v4.5c-.76 0-1.49.24-2.02.66-.54.43-.84 1-.84 1.6v5.62c0 .6.3 1.17.84 1.6.53.41 1.26.65 2.02.65h8.57c.76 0 1.48-.24 2.02-.66.53-.42.84-1 .84-1.59v-5.63c0-.6-.3-1.16-.84-1.59a3.29 3.29 0 0 0-2.02-.65Z"
fill={color || ColorPalette.Blue}

@ -0,0 +1,23 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _PencilIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"
fill={color || ColorPalette.Black}
/>
<path
fillRule="evenodd"
d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const PencilIcon = memo(_PencilIcon);

@ -0,0 +1,18 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _PlusIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const PlusIcon = memo(_PlusIcon);

@ -0,0 +1,18 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _PlusCircleIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" {...rest}>
<path
d="M14.0625 7.03125C14.0625 8.89605 13.3217 10.6845 12.0031 12.0031C10.6845 13.3217 8.89605 14.0625 7.03125 14.0625C5.16645 14.0625 3.37802 13.3217 2.05941 12.0031C0.74079 10.6845 0 8.89605 0 7.03125C0 5.16645 0.74079 3.37802 2.05941 2.05941C3.37802 0.74079 5.16645 0 7.03125 0C8.89605 0 10.6845 0.74079 12.0031 2.05941C13.3217 3.37802 14.0625 5.16645 14.0625 7.03125ZM7.4707 3.95508C7.4707 3.83853 7.4244 3.72675 7.34199 3.64434C7.25958 3.56192 7.1478 3.51562 7.03125 3.51562C6.9147 3.51562 6.80292 3.56192 6.72051 3.64434C6.6381 3.72675 6.5918 3.83853 6.5918 3.95508V6.5918H3.95508C3.83853 6.5918 3.72675 6.6381 3.64434 6.72051C3.56192 6.80292 3.51562 6.9147 3.51562 7.03125C3.51562 7.1478 3.56192 7.25958 3.64434 7.34199C3.72675 7.4244 3.83853 7.4707 3.95508 7.4707H6.5918V10.1074C6.5918 10.224 6.6381 10.3357 6.72051 10.4182C6.80292 10.5006 6.9147 10.5469 7.03125 10.5469C7.1478 10.5469 7.25958 10.5006 7.34199 10.4182C7.4244 10.3357 7.4707 10.224 7.4707 10.1074V7.4707H10.1074C10.224 7.4707 10.3357 7.4244 10.4182 7.34199C10.5006 7.25958 10.5469 7.1478 10.5469 7.03125C10.5469 6.9147 10.5006 6.80292 10.4182 6.72051C10.3357 6.6381 10.224 6.5918 10.1074 6.5918H7.4707V3.95508Z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const PlusCircleIcon = memo(_PlusCircleIcon);

@ -2,24 +2,13 @@ import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
import { DefaultIconProps } from './types.js';
function _QuestionMarkIcon({ width, height, color, classes }: Props) {
function _QuestionMarkIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="13.7 6 20.65 38"
className={classes}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
d="M21.55 31.5q.05-3.6.82-5.25.78-1.65 2.93-3.6 2.1-1.9 3.23-3.52t1.12-3.48q0-2.25-1.5-3.75t-4.2-1.5q-2.6 0-4 1.48t-2.05 3.07l-4.2-1.85q1.1-2.95 3.73-5.03T23.95 6q5 0 7.7 2.77t2.7 6.68q0 2.4-1.02 4.35-1.03 1.95-3.28 4.1-2.45 2.35-2.95 3.6t-.55 4Zm2.4 12.5q-1.45 0-2.48-1.02-1.02-1.03-1.02-2.48t1.02-2.48Q22.5 37 23.95 37t2.48 1.02q1.02 1.03 1.02 2.48t-1.02 2.48Q25.4 44 23.95 44Z"
d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286m1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94"
fill={color || ColorPalette.Black}
/>
</svg>

@ -0,0 +1,18 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _SearchIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const SearchIcon = memo(_SearchIcon);

@ -2,23 +2,12 @@ import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
interface Props {
width?: string | number;
height?: string | number;
color?: string;
classes?: string;
}
import { DefaultIconProps } from './types.js';
// Shield with checkmark
function _ShieldIcon({ width, height, color, classes }: Props) {
function _ShieldIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width={width}
height={height}
className={classes}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
fillRule="evenodd"
d="M8 0c-.69 0-1.843.265-2.928.56-1.11.3-2.229.655-2.887.87a1.54 1.54 0 0 0-1.044 1.262c-.596 4.477.787 7.795 2.465 9.99a11.777 11.777 0 0 0 2.517 2.453c.386.273.744.482 1.048.625.28.132.581.24.829.24s.548-.108.829-.24a7.159 7.159 0 0 0 1.048-.625 11.775 11.775 0 0 0 2.517-2.453c1.678-2.195 3.061-5.513 2.465-9.99a1.541 1.541 0 0 0-1.044-1.263 62.467 62.467 0 0 0-2.887-.87C9.843.266 8.69 0 8 0zm2.146 5.146a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647z"

@ -0,0 +1,33 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _Spinner({ color, className, ...rest }: DefaultIconProps) {
return (
<svg
className={`htw-animate-spin htw-text-black ${className}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...rest}
>
<circle
className="htw-opacity-25"
stroke={color || ColorPalette.Black}
strokeWidth="4"
cx="12"
cy="12"
r="10"
></circle>
<path
className="htw-opacity-75"
fill={color || ColorPalette.Black}
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
);
}
export const Spinner = memo(_Spinner);

@ -0,0 +1,19 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _UpDownArrowsIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}>
<path
fillRule="evenodd"
d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5m-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const UpDownArrowsIcon = memo(_UpDownArrowsIcon);

@ -2,14 +2,12 @@ import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
export interface WideChevronProps {
width?: string | number;
height?: string | number;
import { DefaultIconProps } from './types.js';
export type WideChevronProps = DefaultIconProps & {
direction: 'n' | 'e' | 's' | 'w';
color?: string;
rounded?: boolean;
classes?: string;
}
};
function _WideChevron({
width,
@ -17,7 +15,8 @@ function _WideChevron({
direction,
color,
rounded,
classes,
className,
...rest
}: WideChevronProps) {
let directionClass;
switch (direction) {
@ -45,7 +44,8 @@ function _WideChevron({
width={width}
height={height}
fill={color || ColorPalette.Blue}
className={`${directionClass} ${classes}`}
className={`${directionClass} ${className}`}
{...rest}
>
<path d="M4.4 0h53c7.2 0 13.7 3 16.2 7.7l46.5 85.1a2 2 0 0 1 0 2l-.2.5-46.3 87c-2.5 4.6-9 7.7-16.3 7.7h-53c-3 0-5-2-4-4L48 92.9.4 4c-1-2 1-4 4-4Z" />
</svg>
@ -57,7 +57,8 @@ function _WideChevron({
viewBox="0 0 28 27"
width={width}
height={height}
className={`${directionClass} ${classes}`}
className={`${directionClass} ${className}`}
{...rest}
>
<path
d="M13.44 13.5 0 27h14.56L28 13.5 14.56 0H0l13.44 13.5Z"

@ -0,0 +1,18 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from './types.js';
function _XIcon({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" {...rest}>
<path
d="M10 0.97908L9.02092 0L5 4.02092L0.979081 0L0 0.97908L4.02092 5L0 9.02092L0.979081 10L5 5.97908L9.02092 10L10 9.02092L5.97908 5L10 0.97908Z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const XIcon = memo(_XIcon);

@ -0,0 +1,5 @@
import { SVGProps } from 'react';
export type DefaultIconProps = SVGProps<SVGSVGElement> & {
color?: string;
};

@ -1,8 +1,45 @@
export {
ChainDetailsMenu,
type ChainDetailsMenuProps,
} from './chains/ChainDetailsMenu.js';
export { ChainLogo } from './chains/ChainLogo.js';
export {
ChainSearchMenu,
type ChainSearchMenuProps,
} from './chains/ChainSearchMenu.js';
export { ColorPalette, seedToBgColor } from './color.js';
export { CopyButton } from './components/CopyButton.js';
export { IconButton } from './components/IconButton.js';
export { LinkButton } from './components/LinkButton.js';
export { SegmentedControl } from './components/SegmentedControl.js';
export { TextInput } from './components/TextInput.js';
export { Tooltip } from './components/Tooltip.js';
export * from './consts.js';
export { ChainLogo } from './icons/ChainLogo.js';
export { AirplaneIcon } from './icons/Airplane.js';
export { ArrowIcon } from './icons/Arrow.js';
export { BoxArrowIcon } from './icons/BoxArrow.js';
export { CheckmarkIcon } from './icons/Checkmark.js';
export { ChevronIcon } from './icons/Chevron.js';
export { Circle } from './icons/Circle.js';
export { CopyIcon } from './icons/Copy.js';
export { EnvelopeIcon } from './icons/Envelope.js';
export { FilterIcon } from './icons/Filter.js';
export { FunnelIcon } from './icons/Funnel.js';
export { GearIcon } from './icons/Gear.js';
export { LockIcon } from './icons/Lock.js';
export { PlusIcon } from './icons/Plus.js';
export { PlusCircleIcon } from './icons/PlusCircle.js';
export { QuestionMarkIcon } from './icons/QuestionMark.js';
export { SearchIcon } from './icons/Search.js';
export { ShieldIcon } from './icons/Shield.js';
export { Spinner } from './icons/Spinner.js';
export { UpDownArrowsIcon } from './icons/UpDownArrows.js';
export { WideChevron } from './icons/WideChevron.js';
export { XIcon } from './icons/X.js';
export { DropdownMenu, type DropdownMenuProps } from './layout/DropdownMenu.js';
export { Modal, useModal, type ModalProps } from './layout/Modal.js';
export { Popover, type PopoverProps } from './layout/Popover.js';
export { HyperlaneLogo } from './logos/Hyperlane.js';
export { MessageTimeline } from './messages/MessageTimeline.js';
export {
MessageStage,
@ -13,3 +50,9 @@ export {
export { useMessage } from './messages/useMessage.js';
export { useMessageStage } from './messages/useMessageStage.js';
export { useMessageTimeline } from './messages/useMessageTimeline.js';
export {
isClipboardReadSupported,
tryClipboardGet,
tryClipboardSet,
} from './utils/clipboard.js';
export { useConnectionHealthTest } from './utils/useChainConnectionTest.js';

@ -0,0 +1,45 @@
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import clsx from 'clsx';
import React, { ComponentProps, ReactNode } from 'react';
export type DropdownMenuProps = {
button: ReactNode;
buttonClassname?: string;
buttonProps?: ComponentProps<typeof MenuButton>;
menuClassname?: string;
menuProps?: ComponentProps<typeof MenuItems>;
menuItems: Array<ComponentProps<typeof MenuItem>['children']>;
};
export function DropdownMenu({
button,
buttonClassname,
buttonProps,
menuClassname,
menuProps,
menuItems,
}: DropdownMenuProps) {
return (
<Menu>
<MenuButton
className={clsx('htw-focus:outline-none', buttonClassname)}
{...buttonProps}
>
{button}
</MenuButton>
<MenuItems
transition
anchor="bottom"
className={clsx(
'htw-rounded htw-bg-white htw-shadow-md htw-drop-shadow-md htw-backdrop-blur htw-transition htw-duration-200 htw-ease-in-out htw-focus:outline-none [--anchor-gap:var(--spacing-5)] data-[closed]:htw--translate-y-1 data-[closed]:htw-opacity-0 htw-cursor-pointer htw-z-30',
menuClassname,
)}
{...menuProps}
>
{menuItems.map((mi, i) => (
<MenuItem key={`menu-item-${i}`}>{mi}</MenuItem>
))}
</MenuItems>
</Menu>
);
}

@ -0,0 +1,74 @@
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';
import clsx from 'clsx';
import React, { ComponentProps, PropsWithChildren, useState } from 'react';
import { IconButton } from '../components/IconButton.js';
import { XIcon } from '../icons/X.js';
export function useModal() {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return { isOpen, open, close };
}
export type ModalProps = PropsWithChildren<{
isOpen: boolean;
close: () => void;
dialogClassname?: string;
dialogProps?: ComponentProps<typeof Dialog>;
panelClassname?: string;
panelProps?: ComponentProps<typeof DialogPanel>;
showCloseButton?: boolean;
}>;
export function Modal({
isOpen,
close,
dialogClassname,
dialogProps,
panelClassname,
panelProps,
showCloseButton,
children,
}: ModalProps) {
return (
<Dialog
open={isOpen}
as="div"
className={clsx(
'htw-relative htw-z-20 htw-focus:outline-none',
dialogClassname,
)}
onClose={close}
{...dialogProps}
>
<DialogBackdrop className="htw-fixed htw-inset-0 htw-bg-black/5 htw-transition-all htw-duration-200" />
<div className="htw-fixed htw-inset-0 htw-z-20 htw-w-screen htw-overflow-y-auto">
<div className="htw-flex htw-min-h-full htw-items-center htw-justify-center htw-p-4">
<DialogPanel
transition
className={clsx(
'htw-w-full htw-max-w-sm htw-max-h-[90vh] htw-rounded-lg htw-shadow htw-overflow-auto no-scrollbar htw-bg-white htw-duration-200 htw-ease-out data-[closed]:htw-transform-[scale(95%)] data-[closed]:htw-opacity-0 data-[closed]:htw--translate-y-1',
panelClassname,
)}
{...panelProps}
>
{children}
{showCloseButton && (
<div className="htw-absolute htw-right-3 htw-top-3">
<IconButton
onClick={close}
title="Close"
className="hover:htw-rotate-90"
>
<XIcon height={10} width={10} />
</IconButton>
</div>
)}
</DialogPanel>
</div>
</div>
</Dialog>
);
}

@ -0,0 +1,47 @@
import {
PopoverButton,
PopoverPanel,
Popover as _Popover,
} from '@headlessui/react';
import clsx from 'clsx';
import React, { ComponentProps, ReactNode } from 'react';
export type PopoverProps = {
button: ReactNode;
buttonClassname?: string;
buttonProps?: ComponentProps<typeof PopoverButton>;
panelClassname?: string;
panelProps?: ComponentProps<typeof PopoverPanel>;
children: ComponentProps<typeof PopoverPanel>['children'];
};
export function Popover({
button,
buttonClassname,
buttonProps,
panelClassname,
panelProps,
children,
}: PopoverProps) {
return (
<_Popover>
<PopoverButton
className={clsx('htw-focus:outline-none', buttonClassname)}
{...buttonProps}
>
{button}
</PopoverButton>
<PopoverPanel
transition
anchor="bottom"
className={clsx(
'htw-rounded htw-bg-white htw-border htw-border-gray-100 htw-shadow-md htw-drop-shadow-md htw-backdrop-blur htw-transition htw-duration-200 htw-ease-in-out htw-focus:outline-none [--anchor-gap:var(--spacing-5)] data-[closed]:htw--translate-y-1 data-[closed]:htw-opacity-0 htw-z-30',
panelClassname,
)}
{...panelProps}
>
{children}
</PopoverPanel>
</_Popover>
);
}

@ -0,0 +1,25 @@
import React, { memo } from 'react';
import { ColorPalette } from '../color.js';
import { DefaultIconProps } from '../icons/types.js';
function _HyperlaneLogo({ color, ...rest }: DefaultIconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 117 118" {...rest}>
<path
d="M64.4787 0H88.4134C91.6788 0 94.6004 1.89614 95.7403 4.7553L116.749 57.4498C116.911 57.8563 116.913 58.3035 116.754 58.7112L116.637 59.014L116.635 59.017L95.7152 112.81C94.5921 115.698 91.6551 117.62 88.3666 117.62H64.4355C63.0897 117.62 62.1465 116.379 62.59 115.192L84.1615 57.4498L62.6428 2.45353C62.1766 1.26188 63.1208 0 64.4787 0Z"
fill={color || ColorPalette.Black}
/>
<path
d="M1.99945 0H25.9342C29.1996 0 32.1211 1.89614 33.261 4.7553L54.2696 57.4498C54.4316 57.8563 54.4336 58.3035 54.275 58.7112L54.1573 59.014L54.1561 59.017L33.236 112.81C32.1129 115.698 29.1759 117.62 25.8874 117.62H1.95626C0.610483 117.62 -0.332722 116.379 0.110804 115.192L21.6823 57.4498L0.163626 2.45353C-0.302638 1.26188 0.641544 0 1.99945 0Z"
fill={color || ColorPalette.Black}
/>
<path
d="M80.7202 46.2178H46.9324V71.7089H80.7202L86.2411 58.5992L80.7202 46.2178Z"
fill={color || ColorPalette.Black}
/>
</svg>
);
}
export const HyperlaneLogo = memo(_HyperlaneLogo);

@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import type { MultiProvider } from '@hyperlane-xyz/sdk';
import { fetchWithTimeout } from '@hyperlane-xyz/utils';
import { HYPERLANE_EXPLORER_API_URL } from '../consts.js';
import { queryExplorerForBlock } from '../utils/explorers.js';
import { fetchWithTimeout } from '../utils/timeout.js';
import { useInterval } from '../utils/useInterval.js';
import {

@ -0,0 +1,42 @@
import { Meta, StoryObj } from '@storybook/react';
import { chainMetadata } from '@hyperlane-xyz/registry';
import { ChainDetailsMenu } from '../chains/ChainDetailsMenu.js';
const meta = {
title: 'ChainDetailsMenu',
component: ChainDetailsMenu,
} satisfies Meta<typeof ChainDetailsMenu>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DefaultChainDetails = {
args: {
chainMetadata: chainMetadata['ethereum'],
overrideChainMetadata: undefined,
onChangeOverrideMetadata: () => {},
onClickBack: undefined,
onRemoveChain: undefined,
},
} satisfies Story;
export const PartialOverrideChainDetails = {
args: {
chainMetadata: chainMetadata['ethereum'],
overrideChainMetadata: { rpcUrls: [{ http: 'https://rpc.fakeasdf.com' }] },
onChangeOverrideMetadata: () => {},
onClickBack: undefined,
onRemoveChain: undefined,
},
} satisfies Story;
export const FullOverrideChainDetails = {
args: {
chainMetadata: chainMetadata['arbitrum'],
overrideChainMetadata: chainMetadata['arbitrum'],
onChangeOverrideMetadata: () => {},
onClickBack: () => {},
onRemoveChain: () => {},
},
} satisfies Story;

@ -3,7 +3,7 @@ import React from 'react';
import { GithubRegistry } from '@hyperlane-xyz/registry';
import { ChainLogo } from '../icons/ChainLogo.js';
import { ChainLogo } from '../chains/ChainLogo.js';
export default {
title: 'ChainLogo',
@ -15,6 +15,7 @@ const Template: ComponentStory<typeof ChainLogo> = (args) => (
);
const registry = new GithubRegistry();
await registry.getMetadata();
export const ChainNoBackground = Template.bind({});
ChainNoBackground.args = {
@ -56,3 +57,11 @@ FakeChainName.args = {
chainName: 'myfakechain',
registry,
};
export const SpecificLogoUri = Template.bind({});
SpecificLogoUri.args = {
chainName: 'myfakechain',
logoUri:
'https://raw.githubusercontent.com/hyperlane-xyz/hyperlane-registry/main/chains/arbitrum/logo.svg',
registry,
};

@ -0,0 +1,48 @@
import { Meta, StoryObj } from '@storybook/react';
import { chainMetadata } from '@hyperlane-xyz/registry';
import { pick } from '@hyperlane-xyz/utils';
import { ChainSearchMenu } from '../chains/ChainSearchMenu.js';
const meta = {
title: 'ChainSearchMenu',
component: ChainSearchMenu,
} satisfies Meta<typeof ChainSearchMenu>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DefaultChainSearch = {
args: {
chainMetadata,
onChangeOverrideMetadata: () => {},
onClickChain: (chain) => console.log('Clicked', chain),
},
} satisfies Story;
export const WithCustomField = {
args: {
chainMetadata: pick(chainMetadata, ['alfajores', 'arbitrum', 'ethereum']),
onChangeOverrideMetadata: () => {},
customListItemField: {
header: 'Warp Routes',
data: {
alfajores: { display: '1 token', sortValue: 1 },
arbitrum: { display: '2 tokens', sortValue: 2 },
ethereum: { display: '1 token', sortValue: 1 },
},
},
showAddChainButton: true,
},
} satisfies Story;
export const WithOverrideChain = {
args: {
chainMetadata: pick(chainMetadata, ['alfajores']),
overrideChainMetadata: {
arbitrum: { ...chainMetadata['arbitrum'], displayName: 'Fake Arb' },
},
onChangeOverrideMetadata: () => {},
showAddChainButton: true,
},
} satisfies Story;

@ -0,0 +1,36 @@
import { Button } from '@headlessui/react';
import { Meta, StoryObj } from '@storybook/react';
import React, { useState } from 'react';
import { Modal } from '../layout/Modal.js';
function MyModal() {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return (
<>
<Button onClick={open}>Open modal</Button>
<Modal
isOpen={isOpen}
close={close}
showCloseButton
panelClassname="htw-bg-gray-100"
>
<div>Hello Modal</div>
</Modal>
</>
);
}
const meta = {
title: 'Modal',
component: MyModal,
} satisfies Meta<typeof Modal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const BasicModal = {
args: {},
} satisfies Story;

@ -1,3 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tailwind extension to hide scrollbar */
@layer utilities {
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}

@ -0,0 +1,24 @@
export function isClipboardReadSupported() {
return !!navigator?.clipboard?.readText;
}
export async function tryClipboardSet(value: string) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch (error) {
console.error('Failed to set clipboard', error);
return false;
}
}
export async function tryClipboardGet() {
try {
// Note: doesn't work in firefox, which only allows extensions to read clipboard
const value = await navigator.clipboard.readText();
return value;
} catch (error) {
console.error('Failed to read from clipboard', error);
return null;
}
}

@ -1,6 +1,5 @@
import type { MultiProvider } from '@hyperlane-xyz/sdk';
import { fetchWithTimeout } from './timeout.js';
import { fetchWithTimeout } from '@hyperlane-xyz/utils';
export interface ExplorerQueryResponse<R> {
status: string;

@ -1,14 +0,0 @@
export async function fetchWithTimeout(
resource: RequestInfo,
options?: RequestInit,
timeout = 10000,
) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
const response = await fetch(resource, {
...options,
signal: controller.signal,
});
clearTimeout(id);
return response;
}

@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import {
ChainMetadata,
isBlockExplorerHealthy,
isRpcHealthy,
} from '@hyperlane-xyz/sdk';
import { timeout } from '@hyperlane-xyz/utils';
import { ChainConnectionType } from '../chains/types.js';
const HEALTH_TEST_TIMEOUT = 5000; // 5s
export function useConnectionHealthTest(
chainMetadata: ChainMetadata,
index: number,
type: ChainConnectionType,
) {
const [isHealthy, setIsHealthy] = useState<boolean | undefined>(undefined);
const tester =
type === ChainConnectionType.RPC ? isRpcHealthy : isBlockExplorerHealthy;
useEffect(() => {
// TODO run explorer test through CORS proxy, otherwise it's blocked by browser
if (type === ChainConnectionType.Explorer) return;
timeout(tester(chainMetadata, index), HEALTH_TEST_TIMEOUT)
.then((result) => setIsHealthy(result))
.catch(() => setIsHealthy(false));
}, [chainMetadata, index, tester]);
return isHealthy;
}

@ -25,7 +25,7 @@ module.exports = {
200: '#A7C2EC',
300: '#82A8E4',
400: '#5385D2',
500: '#2362C0',
500: '#2764c1',
600: '#1D4685',
700: '#162A4A',
800: '#11213B',
@ -66,16 +66,16 @@ module.exports = {
900: '#0F2F1E',
},
pink: {
50: '#FAEAF7',
100: '#F0C0E8',
200: '#EBABE0',
300: '#E282D1',
400: '#D858C2',
500: '#CF2FB3',
600: '#BA2AA1',
700: '#A5258F',
800: '#90207D',
900: '#7C1C6B',
50: '#FAEAF8',
100: '#F2C1EA',
200: '#EA98DC',
300: '#E26ECE',
400: '#DA45C0',
500: '#D631B9',
600: '#C02CA6',
700: '#952281',
800: '#6B185C',
900: '#400E37',
}
},
fontSize: {

@ -7571,6 +7571,16 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/dom@npm:^1.6.1":
version: 1.6.10
resolution: "@floating-ui/dom@npm:1.6.10"
dependencies:
"@floating-ui/core": "npm:^1.6.0"
"@floating-ui/utils": "npm:^0.2.7"
checksum: c100f5ecb37fc1bea4e551977eae3992f8eba351e6b7f2642e2f84a4abd269406d5a46a14505bc583caf25ddee900a667829244c4eecf1cf60f08c1dabdf3ee9
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^2.0.0":
version: 2.1.1
resolution: "@floating-ui/react-dom@npm:2.1.1"
@ -7583,6 +7593,32 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^2.1.2":
version: 2.1.2
resolution: "@floating-ui/react-dom@npm:2.1.2"
dependencies:
"@floating-ui/dom": "npm:^1.0.0"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 2a67dc8499674e42ff32c7246bded185bb0fdd492150067caf9568569557ac4756a67787421d8604b0f241e5337de10762aee270d9aeef106d078a0ff13596c4
languageName: node
linkType: hard
"@floating-ui/react@npm:^0.26.16":
version: 0.26.24
resolution: "@floating-ui/react@npm:0.26.24"
dependencies:
"@floating-ui/react-dom": "npm:^2.1.2"
"@floating-ui/utils": "npm:^0.2.8"
tabbable: "npm:^6.0.0"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 903ffbee2c6726d117086e2a83f43d6ad339970758ce7979fd16cc7cf8dc0f5b869bd72c2c8ee1bcd6c63b190bb0960effd4d403e63685fb5aeed6b185041b08
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.2.5":
version: 0.2.5
resolution: "@floating-ui/utils@npm:0.2.5"
@ -7590,6 +7626,20 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.2.7":
version: 0.2.7
resolution: "@floating-ui/utils@npm:0.2.7"
checksum: 56b1bb3f73f6ec9aabf9b1fd3dc584e0f2384d319c1a6119050eab102ae6ca8b9b0eed711c2f235ffe035188cbe9727bf36e8dcb54c8bd32176737e4be47efa8
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.2.8":
version: 0.2.8
resolution: "@floating-ui/utils@npm:0.2.8"
checksum: 3e3ea3b2de06badc4baebdf358b3dbd77ccd9474a257a6ef237277895943db2acbae756477ec64de65a2a1436d94aea3107129a1feeef6370675bf2b161c1abc
languageName: node
linkType: hard
"@ganache/ethereum-address@npm:0.1.4":
version: 0.1.4
resolution: "@ganache/ethereum-address@npm:0.1.4"
@ -7706,6 +7756,21 @@ __metadata:
languageName: node
linkType: hard
"@headlessui/react@npm:^2.1.8":
version: 2.1.8
resolution: "@headlessui/react@npm:2.1.8"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@react-aria/focus": "npm:^3.17.1"
"@react-aria/interactions": "npm:^3.21.3"
"@tanstack/react-virtual": "npm:^3.8.1"
peerDependencies:
react: ^18
react-dom: ^18
checksum: a82f115877dcc5e3d16a6b0502b6796a5bd3f38936835e241833a538c002d4ecfc3317868b0d1e9655e5de93201b0806f51bc10dbf32604e270cda4fc1636024
languageName: node
linkType: hard
"@humanwhocodes/config-array@npm:^0.11.14":
version: 0.11.14
resolution: "@humanwhocodes/config-array@npm:0.11.14"
@ -8043,8 +8108,10 @@ __metadata:
version: 0.0.0-use.local
resolution: "@hyperlane-xyz/widgets@workspace:typescript/widgets"
dependencies:
"@headlessui/react": "npm:^2.1.8"
"@hyperlane-xyz/registry": "npm:4.3.6"
"@hyperlane-xyz/sdk": "npm:5.4.0"
"@hyperlane-xyz/utils": "npm:5.4.0"
"@storybook/addon-essentials": "npm:^7.6.14"
"@storybook/addon-interactions": "npm:^7.6.14"
"@storybook/addon-links": "npm:^7.6.14"
@ -8060,6 +8127,7 @@ __metadata:
"@typescript-eslint/eslint-plugin": "npm:^7.4.0"
"@typescript-eslint/parser": "npm:^7.4.0"
babel-loader: "npm:^8.3.0"
clsx: "npm:^2.1.1"
eslint: "npm:^8.57.0"
eslint-config-prettier: "npm:^9.1.0"
eslint-plugin-storybook: "npm:^0.6.15"
@ -8067,8 +8135,9 @@ __metadata:
prettier: "npm:^2.8.8"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-tooltip: "npm:^5.28.0"
storybook: "npm:^7.6.14"
tailwindcss: "npm:^3.2.4"
tailwindcss: "npm:^3.4.13"
ts-node: "npm:^10.8.0"
typescript: "npm:5.3.3"
vite: "npm:^5.1.1"
@ -10617,6 +10686,81 @@ __metadata:
languageName: node
linkType: hard
"@react-aria/focus@npm:^3.17.1":
version: 3.18.2
resolution: "@react-aria/focus@npm:3.18.2"
dependencies:
"@react-aria/interactions": "npm:^3.22.2"
"@react-aria/utils": "npm:^3.25.2"
"@react-types/shared": "npm:^3.24.1"
"@swc/helpers": "npm:^0.5.0"
clsx: "npm:^2.0.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
checksum: 4243764952737ec33f463534e69c7d581073d5531ae87504d574083a4d9a08a9e3b5a8e2b69a936bf6476a35eb8cf38db751d52629e66451be58a6c635ce9449
languageName: node
linkType: hard
"@react-aria/interactions@npm:^3.21.3, @react-aria/interactions@npm:^3.22.2":
version: 3.22.2
resolution: "@react-aria/interactions@npm:3.22.2"
dependencies:
"@react-aria/ssr": "npm:^3.9.5"
"@react-aria/utils": "npm:^3.25.2"
"@react-types/shared": "npm:^3.24.1"
"@swc/helpers": "npm:^0.5.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
checksum: df0ce7d438b6f9d04774120ed6a3b66ef928e8e8ce97af42b12a5feabcd8d6cdd858e14cd6ccf602bbe8c0dbb620ce94bd974f1e2b832f497c7125647f8be471
languageName: node
linkType: hard
"@react-aria/ssr@npm:^3.9.5":
version: 3.9.5
resolution: "@react-aria/ssr@npm:3.9.5"
dependencies:
"@swc/helpers": "npm:^0.5.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
checksum: 0284561e7b084c567fd8f35e7982f201582acc937b950be8411678352682c7b45ad3ab99272cd2d6f0b4919ddaa5b0e553d784f190d1d05ceb8594bfee3f763e
languageName: node
linkType: hard
"@react-aria/utils@npm:^3.25.2":
version: 3.25.2
resolution: "@react-aria/utils@npm:3.25.2"
dependencies:
"@react-aria/ssr": "npm:^3.9.5"
"@react-stately/utils": "npm:^3.10.3"
"@react-types/shared": "npm:^3.24.1"
"@swc/helpers": "npm:^0.5.0"
clsx: "npm:^2.0.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
checksum: c0dbbff1f93b3f275e6db2f01c7a09ffd96da57fd373a8b3b3cb5dbb0aca99d721c2453fbd742800d0df2fbb0ffa5f3052669bbb2998db753b1090f573d5ef7b
languageName: node
linkType: hard
"@react-stately/utils@npm:^3.10.3":
version: 3.10.3
resolution: "@react-stately/utils@npm:3.10.3"
dependencies:
"@swc/helpers": "npm:^0.5.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
checksum: 0ac737e678d949787d05889bfd67047ed0ee91d93a8d727c89d7a7568a027d0cf4a53cebad13e6526c2322f51069bbaa40d5912364230e6b9374cf653683a73d
languageName: node
linkType: hard
"@react-types/shared@npm:^3.24.1":
version: 3.24.1
resolution: "@react-types/shared@npm:3.24.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
checksum: 5472ae35f65b2ed7c12d5ea4459f34b4aec065d2633844031d27945495b6dca6fa9bf02b6392b901fac97252e58d9b91a4baf53f4c281397fb81ce85c73b8648
languageName: node
linkType: hard
"@resolver-engine/core@npm:^0.3.3":
version: 0.3.3
resolution: "@resolver-engine/core@npm:0.3.3"
@ -12658,6 +12802,15 @@ __metadata:
languageName: node
linkType: hard
"@swc/helpers@npm:^0.5.0":
version: 0.5.13
resolution: "@swc/helpers@npm:0.5.13"
dependencies:
tslib: "npm:^2.4.0"
checksum: 6ba2f7e215d32d71fce139e2cfc426b3ed7eaa709febdeb07b97260a4c9eea4784cf047cc1271be273990b08220b576b94a42b5780947c0b3be84973a847a24d
languageName: node
linkType: hard
"@szmarczak/http-timer@npm:^5.0.1":
version: 5.0.1
resolution: "@szmarczak/http-timer@npm:5.0.1"
@ -12667,6 +12820,25 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/react-virtual@npm:^3.8.1":
version: 3.10.8
resolution: "@tanstack/react-virtual@npm:3.10.8"
dependencies:
"@tanstack/virtual-core": "npm:3.10.8"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 40a5d6089908096634fec2aa0cd646ca47c044c745e1b0d190ecbf9905ad2e6266ccd56c2550ed92f47349954dc11eb6930beac1354441ce7c98af81c5454d3f
languageName: node
linkType: hard
"@tanstack/virtual-core@npm:3.10.8":
version: 3.10.8
resolution: "@tanstack/virtual-core@npm:3.10.8"
checksum: 047e95fa72a0d341c0da8468799c176fd448481432f976a4780911bb4a2256aa4788d828f79fad78d127fe859b785189c13ca0fea10c560bf14d8ab8cb2c7790
languageName: node
linkType: hard
"@testing-library/dom@npm:^9.3.1":
version: 9.3.4
resolution: "@testing-library/dom@npm:9.3.4"
@ -16318,6 +16490,13 @@ __metadata:
languageName: node
linkType: hard
"classnames@npm:^2.3.0":
version: 2.5.1
resolution: "classnames@npm:2.5.1"
checksum: 58eb394e8817021b153bb6e7d782cfb667e4ab390cb2e9dac2fc7c6b979d1cc2b2a733093955fc5c94aa79ef5c8c89f11ab77780894509be6afbb91dddd79d15
languageName: node
linkType: hard
"clean-stack@npm:^2.0.0":
version: 2.2.0
resolution: "clean-stack@npm:2.2.0"
@ -16500,6 +16679,13 @@ __metadata:
languageName: node
linkType: hard
"clsx@npm:^2.0.0, clsx@npm:^2.1.1":
version: 2.1.1
resolution: "clsx@npm:2.1.1"
checksum: cdfb57fa6c7649bbff98d9028c2f0de2f91c86f551179541cf784b1cfdc1562dcb951955f46d54d930a3879931a980e32a46b598acaea274728dbe068deca919
languageName: node
linkType: hard
"co@npm:^4.6.0":
version: 4.6.0
resolution: "co@npm:4.6.0"
@ -26947,6 +27133,19 @@ __metadata:
languageName: node
linkType: hard
"react-tooltip@npm:^5.28.0":
version: 5.28.0
resolution: "react-tooltip@npm:5.28.0"
dependencies:
"@floating-ui/dom": "npm:^1.6.1"
classnames: "npm:^2.3.0"
peerDependencies:
react: ">=16.14.0"
react-dom: ">=16.14.0"
checksum: ec13ad0fafcae51c9c1193c6f0bccba4e7047e9d02eaf77231474cefd1a3d05254e76f27229808e79dad4c0a8c47b8e5cafdad47920e34a11d7a2703adf5f998
languageName: node
linkType: hard
"react@npm:^18.2.0":
version: 18.3.1
resolution: "react@npm:18.3.1"
@ -29466,6 +29665,13 @@ __metadata:
languageName: node
linkType: hard
"tabbable@npm:^6.0.0":
version: 6.2.0
resolution: "tabbable@npm:6.2.0"
checksum: 980fa73476026e99dcacfc0d6e000d41d42c8e670faf4682496d30c625495e412c4369694f2a15cf1e5252d22de3c396f2b62edbe8d60b5dadc40d09e3f2dde3
languageName: node
linkType: hard
"table-layout@npm:^1.0.2":
version: 1.0.2
resolution: "table-layout@npm:1.0.2"
@ -29504,9 +29710,9 @@ __metadata:
languageName: node
linkType: hard
"tailwindcss@npm:^3.2.4":
version: 3.4.7
resolution: "tailwindcss@npm:3.4.7"
"tailwindcss@npm:^3.4.13":
version: 3.4.13
resolution: "tailwindcss@npm:3.4.13"
dependencies:
"@alloc/quick-lru": "npm:^5.2.0"
arg: "npm:^5.0.2"
@ -29533,7 +29739,7 @@ __metadata:
bin:
tailwind: lib/cli.js
tailwindcss: lib/cli.js
checksum: bda3280905b05bb3e7e95a350e028a58a19336a854ebebe65816c7625ec49ba4d2af7ef82c169407f67cbf150fb6acbe1210e8ade7e0180fa8039e3607077304
checksum: 01b8dd35a65a028474c632b9ea7fb38634060a2c70f1f3fdfa2fe6ec74dec8224e2ee1178a5428182849790dad324e7a810de7301a9126946528c59d37f455cf
languageName: node
linkType: hard

Loading…
Cancel
Save