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 = () => {
- +
);