diff --git a/src/components/nav/FloatingButtonStrip.tsx b/src/components/nav/FloatingButtonStrip.tsx
new file mode 100644
index 00000000..f2caf7ea
--- /dev/null
+++ b/src/components/nav/FloatingButtonStrip.tsx
@@ -0,0 +1,57 @@
+import Link from 'next/link';
+
+import { DocsIcon, HistoryIcon, IconButton, PlusIcon, useModal } from '@hyperlane-xyz/widgets';
+import { config } from '../../consts/config';
+import { links } from '../../consts/links';
+import { useStore } from '../../features/store';
+import { AddWarpConfigModal } from '../../features/warpCore/AddWarpConfigModal';
+import { Color } from '../../styles/Color';
+
+export function FloatingButtonStrip() {
+ const { setIsSideBarOpen, isSideBarOpen } = useStore((s) => ({
+ setIsSideBarOpen: s.setIsSideBarOpen,
+ isSideBarOpen: s.isSideBarOpen,
+ }));
+
+ const {
+ isOpen: isAddWarpConfigOpen,
+ open: openAddWarpConfig,
+ close: closeAddWarpConfig,
+ } = useModal();
+
+ return (
+ <>
+
+
setIsSideBarOpen(!isSideBarOpen)}
+ >
+
+
+ {config.showAddRouteButton && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+const styles = {
+ link: 'hover:opacity-70 active:opacity-60',
+ roundedCircle: 'rounded-full bg-white',
+};
diff --git a/src/consts/config.ts b/src/consts/config.ts
index ee80b349..2f46baf8 100644
--- a/src/consts/config.ts
+++ b/src/consts/config.ts
@@ -18,6 +18,7 @@ interface Config {
registryUrl: string | undefined; // Optional URL to use a custom registry instead of the published canonical version
registryBranch?: string | undefined; // Optional customization of the registry branch instead of main
registryProxyUrl?: string; // Optional URL to use a custom proxy for the GithubRegistry
+ showAddRouteButton: boolean; // Show/Hide the add route config icon in the button strip
showDisabledTokens: boolean; // Show/Hide invalid token options in the selection modal
showTipBox: boolean; // Show/Hide the blue tip box above the transfer form
transferBlacklist: string; // comma-separated list of routes between which transfers are disabled. Expects Caip2Id-Caip2Id (e.g. ethereum:1-sealevel:1399811149)
@@ -33,6 +34,7 @@ export const config: Config = Object.freeze({
registryUrl,
registryBranch,
registryProxyUrl,
+ showAddRouteButton: true,
showDisabledTokens: false,
showTipBox: true,
version,
diff --git a/src/features/store.ts b/src/features/store.ts
index dc90b9a4..5173c7e8 100644
--- a/src/features/store.ts
+++ b/src/features/store.ts
@@ -1,5 +1,11 @@
import { GithubRegistry, IRegistry } from '@hyperlane-xyz/registry';
-import { ChainMap, ChainMetadata, MultiProtocolProvider, WarpCore } from '@hyperlane-xyz/sdk';
+import {
+ ChainMap,
+ ChainMetadata,
+ MultiProtocolProvider,
+ WarpCore,
+ WarpCoreConfig,
+} from '@hyperlane-xyz/sdk';
import { objFilter } from '@hyperlane-xyz/utils';
import { toast } from 'react-toastify';
import { create } from 'zustand';
@@ -7,8 +13,8 @@ import { persist } from 'zustand/middleware';
import { config } from '../consts/config';
import { logger } from '../utils/logger';
import { assembleChainMetadata } from './chains/metadata';
-import { assembleWarpCoreConfig } from './tokens/warpCoreConfig';
import { FinalTransferStatuses, TransferContext, TransferStatus } from './transfer/types';
+import { assembleWarpCoreConfig } from './warpCore/warpCoreConfig';
// Increment this when persist state has breaking changes
const PERSIST_STATE_VERSION = 2;
@@ -18,8 +24,12 @@ const PERSIST_STATE_VERSION = 2;
export interface AppState {
// Chains and providers
chainMetadata: ChainMap;
+ // Overrides to chain metadata set by user via the chain picker
chainMetadataOverrides: ChainMap>;
setChainMetadataOverrides: (overrides?: ChainMap | undefined>) => void;
+ // Overrides to warp core configs added by user
+ warpCoreConfigOverrides: WarpCoreConfig[];
+ setWarpCoreConfigOverrides: (overrides?: WarpCoreConfig[] | undefined) => void;
multiProvider: MultiProtocolProvider;
registry: IRegistry;
warpCore: WarpCore;
@@ -61,9 +71,21 @@ export const useStore = create()(
overrides: ChainMap | undefined> = {},
) => {
logger.debug('Setting chain overrides in store');
- const { multiProvider } = await initWarpContext(get().registry, overrides);
+ const { multiProvider, warpCore } = await initWarpContext({
+ ...get(),
+ chainMetadataOverrides: overrides,
+ });
const filtered = objFilter(overrides, (_, metadata) => !!metadata);
- set({ chainMetadataOverrides: filtered, multiProvider });
+ set({ chainMetadataOverrides: filtered, multiProvider, warpCore });
+ },
+ warpCoreConfigOverrides: [],
+ setWarpCoreConfigOverrides: async (overrides: WarpCoreConfig[] | undefined = []) => {
+ logger.debug('Setting warp core config overrides in store');
+ const { multiProvider, warpCore } = await initWarpContext({
+ ...get(),
+ warpCoreConfigOverrides: overrides,
+ });
+ set({ warpCoreConfigOverrides: overrides, multiProvider, warpCore });
},
multiProvider: new MultiProtocolProvider({}),
registry: new GithubRegistry({
@@ -137,31 +159,34 @@ export const useStore = create()(
logger.error('Error during hydration', error);
return;
}
- initWarpContext(state.registry, state.chainMetadataOverrides).then(
- ({ registry, chainMetadata, multiProvider, warpCore }) => {
- state.setWarpContext({ registry, chainMetadata, multiProvider, warpCore });
- logger.debug('Rehydration complete');
- },
- );
+ initWarpContext(state).then(({ registry, chainMetadata, multiProvider, warpCore }) => {
+ state.setWarpContext({ registry, chainMetadata, multiProvider, warpCore });
+ logger.debug('Rehydration complete');
+ });
};
},
},
),
);
-async function initWarpContext(
- registry: IRegistry,
- storeMetadataOverrides: ChainMap | undefined>,
-) {
+async function initWarpContext({
+ registry,
+ chainMetadataOverrides,
+ warpCoreConfigOverrides,
+}: {
+ registry: IRegistry;
+ chainMetadataOverrides: ChainMap | undefined>;
+ warpCoreConfigOverrides: WarpCoreConfig[];
+}) {
try {
- const coreConfig = await assembleWarpCoreConfig();
+ const coreConfig = await assembleWarpCoreConfig(warpCoreConfigOverrides);
const chainsInTokens = Array.from(new Set(coreConfig.tokens.map((t) => t.chainName)));
// Pre-load registry content to avoid repeated requests
await registry.listRegistryContent();
const { chainMetadata, chainMetadataWithOverrides } = await assembleChainMetadata(
chainsInTokens,
registry,
- storeMetadataOverrides,
+ chainMetadataOverrides,
);
const multiProvider = new MultiProtocolProvider(chainMetadataWithOverrides);
const warpCore = WarpCore.FromConfig(multiProvider, coreConfig);
diff --git a/src/features/tokens/warpCoreConfig.ts b/src/features/tokens/warpCoreConfig.ts
deleted file mode 100644
index 81a61e8c..00000000
--- a/src/features/tokens/warpCoreConfig.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { warpRouteConfigs } from '@hyperlane-xyz/registry';
-import { WarpCoreConfig, WarpCoreConfigSchema, validateZodResult } from '@hyperlane-xyz/sdk';
-import { objFilter, objMerge } from '@hyperlane-xyz/utils';
-import { warpRouteWhitelist } from '../../consts/warpRouteWhitelist.ts';
-import { warpRouteConfigs as WarpRoutesTs } from '../../consts/warpRoutes.ts';
-import WarpRoutesYaml from '../../consts/warpRoutes.yaml';
-
-export function assembleWarpCoreConfig(): WarpCoreConfig {
- const resultYaml = WarpCoreConfigSchema.safeParse(WarpRoutesYaml);
- const configYaml = validateZodResult(resultYaml, 'warp core yaml config');
- const resultTs = WarpCoreConfigSchema.safeParse(WarpRoutesTs);
- const configTs = validateZodResult(resultTs, 'warp core typescript config');
-
- const filteredWarpRouteConfigs = warpRouteWhitelist
- ? filterToIds(warpRouteConfigs, warpRouteWhitelist)
- : warpRouteConfigs;
-
- const configValues = Object.values(filteredWarpRouteConfigs);
-
- const configTokens = configValues.map((c) => c.tokens).flat();
- const tokens = dedupeTokens([...configTokens, ...configTs.tokens, ...configYaml.tokens]);
-
- if (!tokens.length)
- throw new Error(
- 'No warp route configs provided. Please check your registry, warp route whitelist, and custom route configs for issues.',
- );
-
- const configOptions = configValues.map((c) => c.options).flat();
- const combinedOptions = [...configOptions, configTs.options, configYaml.options];
- const options = combinedOptions.reduce((acc, o) => {
- if (!o || !acc) return acc;
- for (const key of Object.keys(o)) {
- acc[key] = (acc[key] || []).concat(o[key] || []);
- }
- return acc;
- }, {});
-
- return { tokens, options };
-}
-
-function filterToIds(
- config: Record,
- idWhitelist: string[],
-): Record {
- return objFilter(config, (id, c): c is WarpCoreConfig => idWhitelist.includes(id));
-}
-
-// Separate warp configs may contain duplicate definitions of the same token.
-// E.g. an IBC token that gets used for interchain gas in many different routes.
-function dedupeTokens(tokens: WarpCoreConfig['tokens']): WarpCoreConfig['tokens'] {
- const idToToken: Record = {};
- for (const token of tokens) {
- const id = `${token.chainName}|${token.addressOrDenom?.toLowerCase()}`;
- idToToken[id] = objMerge(idToToken[id] || {}, token);
- }
- return Object.values(idToToken);
-}
diff --git a/src/features/wallet/WalletFloatingButtons.tsx b/src/features/wallet/WalletFloatingButtons.tsx
deleted file mode 100644
index e93093e9..00000000
--- a/src/features/wallet/WalletFloatingButtons.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import Link from 'next/link';
-
-import { DocsIcon, HistoryIcon, IconButton } from '@hyperlane-xyz/widgets';
-import { links } from '../../consts/links';
-import { Color } from '../../styles/Color';
-import { useStore } from '../store';
-
-export function WalletFloatingButtons() {
- const { setIsSideBarOpen, isSideBarOpen } = useStore((s) => ({
- setIsSideBarOpen: s.setIsSideBarOpen,
- isSideBarOpen: s.isSideBarOpen,
- }));
-
- return (
-
- setIsSideBarOpen(!isSideBarOpen)}
- >
-
-
-
-
-
-
- );
-}
-
-const styles = {
- link: 'hover:opacity-70 active:opacity-60',
- roundedCircle: 'rounded-full bg-white',
-};
diff --git a/src/features/warpCore/AddWarpConfigModal.tsx b/src/features/warpCore/AddWarpConfigModal.tsx
new file mode 100644
index 00000000..697662c6
--- /dev/null
+++ b/src/features/warpCore/AddWarpConfigModal.tsx
@@ -0,0 +1,170 @@
+import { warpRouteConfigToId } from '@hyperlane-xyz/registry';
+import { MultiProtocolProvider, WarpCoreConfig, WarpCoreConfigSchema } from '@hyperlane-xyz/sdk';
+import { failure, Result, success, tryParseJsonOrYaml } from '@hyperlane-xyz/utils';
+import { Button, CopyButton, IconButton, Modal, PlusIcon, XIcon } from '@hyperlane-xyz/widgets';
+import clsx from 'clsx';
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+import { Color } from '../../styles/Color';
+import { logger } from '../../utils/logger';
+import { useMultiProvider } from '../chains/hooks';
+import { useStore } from '../store';
+
+export function AddWarpConfigModal({ isOpen, close }: { isOpen: boolean; close: () => void }) {
+ const { warpCoreConfigOverrides, setWarpCoreConfigOverrides } = useStore(
+ ({ warpCoreConfigOverrides, setWarpCoreConfigOverrides }) => ({
+ warpCoreConfigOverrides,
+ setWarpCoreConfigOverrides,
+ }),
+ );
+
+ const onAddConfig = (warpCoreConfig: WarpCoreConfig) => {
+ setWarpCoreConfigOverrides([...warpCoreConfigOverrides, warpCoreConfig]);
+ toast.success('Warp config added!');
+ close();
+ };
+
+ const onRemoveConfig = (index: number) => {
+ setWarpCoreConfigOverrides(warpCoreConfigOverrides.filter((_, i) => i !== index));
+ toast.success('Warp config removed');
+ };
+
+ return (
+
+ Add Warp Route Configs
+
+ Add warp route configs, like those from the Hyperlane CLI. Note, these routes will be
+ available only in your own browser.
+
+
+
+
+ );
+}
+
+// TODO de-dupe with Form in ChainAddMenu in widgets lib
+function Form({ onAdd }: { onAdd: (warpCoreConfig: WarpCoreConfig) => void }) {
+ const multiProvider = useMultiProvider();
+ const [textInput, setTextInput] = useState('');
+ const [error, setError] = useState(null);
+
+ const onChangeInput = (e: React.ChangeEvent) => {
+ setTextInput(e.target.value);
+ setError(null);
+ };
+
+ const onClickAdd = () => {
+ const result = tryParseConfigInput(textInput, multiProvider);
+ if (result.success) {
+ onAdd(result.data);
+ } else {
+ setError(`Invalid config: ${result.error}`);
+ }
+ };
+
+ return (
+ <>
+
+
+ {error &&
{error}
}
+
+
+
+ >
+ );
+}
+
+function ConfigList({
+ warpCoreConfigOverrides,
+ onRemove,
+}: {
+ warpCoreConfigOverrides: WarpCoreConfig[];
+ onRemove: (index: number) => void;
+}) {
+ if (!warpCoreConfigOverrides.length) return null;
+
+ return (
+
+ {warpCoreConfigOverrides.map((config, i) => (
+
+ {warpRouteConfigToId(config)}
+ onRemove(i)} title="Remove config">
+
+
+
+ ))}
+
+ );
+}
+
+function tryParseConfigInput(
+ input: string,
+ multiProvider: MultiProtocolProvider,
+): Result {
+ const parsed = tryParseJsonOrYaml(input);
+ if (!parsed.success) return parsed;
+
+ const result = WarpCoreConfigSchema.safeParse(parsed.data);
+
+ if (!result.success) {
+ logger.warn('Error validating warp config', result.error);
+ const firstIssue = result.error.issues[0];
+ return failure(`${firstIssue.path} => ${firstIssue.message}`);
+ }
+
+ const warpConfig = result.data;
+ const warpChains = warpConfig.tokens.map((t) => t.chainName);
+ const unknownChain = warpChains.find((c) => !multiProvider.hasChain(c));
+
+ if (unknownChain) {
+ return failure(`Unknown chain: ${unknownChain}`);
+ }
+
+ return success(result.data);
+}
+
+const placeholderText = `# YAML config data
+---
+tokens:
+ - addressOrDenom: "0x123..."
+ chainName: ethereum
+ collateralAddressOrDenom: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
+ connections:
+ - token: ethereum|mycoolchain|0x345...
+ decimals: 6
+ name: USDC
+ standard: EvmHypCollateral
+ symbol: USDC
+ - addressOrDenom: "0x345..."
+ chainName: mycoolchain
+ connections:
+ - token: ethereum|ethereum|0x123...
+ decimals: 6
+ name: USDC
+ standard: EvmHypSynthetic
+ symbol: USDC
+options: {}
+`;
diff --git a/src/features/warpCore/warpCoreConfig.ts b/src/features/warpCore/warpCoreConfig.ts
new file mode 100644
index 00000000..d77f6b8c
--- /dev/null
+++ b/src/features/warpCore/warpCoreConfig.ts
@@ -0,0 +1,75 @@
+import { warpRouteConfigs as registryWarpRoutes } from '@hyperlane-xyz/registry';
+import { WarpCoreConfig, WarpCoreConfigSchema, validateZodResult } from '@hyperlane-xyz/sdk';
+import { objFilter, objMerge } from '@hyperlane-xyz/utils';
+import { warpRouteWhitelist } from '../../consts/warpRouteWhitelist.ts';
+import { warpRouteConfigs as tsWarpRoutes } from '../../consts/warpRoutes.ts';
+import yamlWarpRoutes from '../../consts/warpRoutes.yaml';
+
+export function assembleWarpCoreConfig(storeOverrides: WarpCoreConfig[]): WarpCoreConfig {
+ const yamlResult = WarpCoreConfigSchema.safeParse(yamlWarpRoutes);
+ const yamlConfig = validateZodResult(yamlResult, 'warp core yaml config');
+ const tsResult = WarpCoreConfigSchema.safeParse(tsWarpRoutes);
+ const tsConfig = validateZodResult(tsResult, 'warp core typescript config');
+
+ const filteredRegistryConfigMap = warpRouteWhitelist
+ ? filterToIds(registryWarpRoutes, warpRouteWhitelist)
+ : registryWarpRoutes;
+ const filteredRegistryConfigValues = Object.values(filteredRegistryConfigMap);
+ const filteredRegistryTokens = filteredRegistryConfigValues.map((c) => c.tokens).flat();
+ const filteredRegistryOptions = filteredRegistryConfigValues.map((c) => c.options).flat();
+
+ const storeOverrideTokens = storeOverrides.map((c) => c.tokens).flat();
+ const storeOverrideOptions = storeOverrides.map((c) => c.options).flat();
+
+ const combinedTokens = [
+ ...filteredRegistryTokens,
+ ...tsConfig.tokens,
+ ...yamlConfig.tokens,
+ ...storeOverrideTokens,
+ ];
+ const tokens = dedupeTokens(combinedTokens);
+
+ const combinedOptions = [
+ ...filteredRegistryOptions,
+ tsConfig.options,
+ yamlConfig.options,
+ ...storeOverrideOptions,
+ ];
+ const options = reduceOptions(combinedOptions);
+
+ if (!tokens.length)
+ throw new Error(
+ 'No warp route configs provided. Please check your registry, warp route whitelist, and custom route configs for issues.',
+ );
+
+ return { tokens, options };
+}
+
+function filterToIds(
+ config: Record,
+ idWhitelist: string[],
+): Record {
+ return objFilter(config, (id, c): c is WarpCoreConfig => idWhitelist.includes(id));
+}
+
+// Separate warp configs may contain duplicate definitions of the same token.
+// E.g. an IBC token that gets used for interchain gas in many different routes.
+function dedupeTokens(tokens: WarpCoreConfig['tokens']): WarpCoreConfig['tokens'] {
+ const idToToken: Record = {};
+ for (const token of tokens) {
+ const id = `${token.chainName}|${token.addressOrDenom?.toLowerCase()}`;
+ idToToken[id] = objMerge(idToToken[id] || {}, token);
+ }
+ return Object.values(idToToken);
+}
+
+// Combine a list of WarpCore option objects into one single options object
+function reduceOptions(optionsList: Array): WarpCoreConfig['options'] {
+ return optionsList.reduce((acc, o) => {
+ if (!o || !acc) return acc;
+ for (const key of Object.keys(o)) {
+ acc[key] = (acc[key] || []).concat(o[key] || []);
+ }
+ return acc;
+ }, {});
+}
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 3fb706aa..0b02979a 100755
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -1,7 +1,7 @@
import type { NextPage } from 'next';
+import { FloatingButtonStrip } from '../components/nav/FloatingButtonStrip';
import { TipCard } from '../components/tip/TipCard';
import { TransferTokenCard } from '../features/transfer/TransferTokenCard';
-import { WalletFloatingButtons } from '../features/wallet/WalletFloatingButtons';
const Home: NextPage = () => {
return (
@@ -9,7 +9,7 @@ const Home: NextPage = () => {
-
+
);