Migration to warp core (#134)

Migrate to WarpCore
  * Upgrade to 3.8.0
  * Form bug fixes
  * Fix cosmos wallet hook log

Local gas estimation and smart max button (#139)
  * Implementing new useFeeQuote hook 
  * Implement smarter max button
  * Finish smart max computation and gas handling
  * Improve form loading states
pull/152/head
J M Rossy 8 months ago committed by GitHub
parent d01d616e77
commit 6127398720
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .eslintrc
  2. 9
      .vscode/settings.json
  3. 16
      package.json
  4. 4
      src/components/animation/SmallSpinner.tsx
  5. 10
      src/components/buttons/ConnectAwareSubmitButton.tsx
  6. 55
      src/components/icons/ChainLogo.tsx
  7. 9
      src/components/icons/TokenIcon.tsx
  8. 16
      src/components/toast/TxSuccessToast.tsx
  9. 4
      src/consts/ibcRoutes.ts
  10. 11
      src/consts/igpQuotes.ts
  11. 5
      src/consts/tokens.json
  12. 31
      src/consts/tokens.ts
  13. 34
      src/consts/tokens.yaml
  14. 4
      src/context/README.md
  15. 16
      src/context/chains.ts
  16. 61
      src/context/context.ts
  17. 19
      src/context/tokens.ts
  18. 67
      src/features/caip/chains.ts
  19. 128
      src/features/caip/tokens.ts
  20. 20
      src/features/chains/ChainSelectField.tsx
  21. 14
      src/features/chains/ChainSelectModal.tsx
  22. 1
      src/features/chains/cosmosDefault.ts
  23. 9
      src/features/chains/metadata.ts
  24. 37
      src/features/chains/utils.ts
  25. 32
      src/features/multiProvider.ts
  26. 19
      src/features/routes/hooks.ts
  27. 54
      src/features/routes/types.ts
  28. 83
      src/features/routes/utils.ts
  29. 36
      src/features/store.ts
  30. 249
      src/features/tokens/AdapterFactory.ts
  31. 64
      src/features/tokens/SelectOrInputTokenIds.tsx
  32. 9
      src/features/tokens/SelectTokenIdField.tsx
  33. 92
      src/features/tokens/TokenListModal.tsx
  34. 82
      src/features/tokens/TokenSelectField.tsx
  35. 57
      src/features/tokens/approval.ts
  36. 233
      src/features/tokens/balances.ts
  37. 60
      src/features/tokens/contracts/evmContracts.ts
  38. 19
      src/features/tokens/metadata.ts
  39. 125
      src/features/tokens/types.ts
  40. 3
      src/features/transfer/TransferTokenCard.tsx
  41. 395
      src/features/transfer/TransferTokenForm.tsx
  42. 60
      src/features/transfer/TransfersDetailsModal.tsx
  43. 45
      src/features/transfer/maxAmount.ts
  44. 43
      src/features/transfer/types.ts
  45. 27
      src/features/transfer/useBalanceWatcher.ts
  46. 46
      src/features/transfer/useFeeQuotes.ts
  47. 118
      src/features/transfer/useIgpQuote.ts
  48. 409
      src/features/transfer/useTokenTransfer.ts
  49. 39
      src/features/transfer/utils.ts
  50. 223
      src/features/transfer/validateForm.ts
  51. 169
      src/features/wallet/SideBarMenu.tsx
  52. 12
      src/features/wallet/context/EvmWalletContext.tsx
  53. 62
      src/features/wallet/hooks/cosmos.ts
  54. 35
      src/features/wallet/hooks/evm.ts
  55. 41
      src/features/wallet/hooks/multiProtocol.tsx
  56. 22
      src/features/wallet/hooks/solana.ts
  57. 17
      src/features/wallet/hooks/types.ts
  58. 4
      src/global.d.ts
  59. 9
      src/scripts/buildConfigs/build.sh
  60. 46
      src/scripts/buildConfigs/index.ts
  61. 261
      src/scripts/buildConfigs/routes.test.ts
  62. 212
      src/scripts/buildConfigs/routes.ts
  63. 132
      src/scripts/buildConfigs/tokens.ts
  64. 6
      src/scripts/buildConfigs/utils.ts
  65. 13
      src/utils/links.ts
  66. 5
      src/utils/transfer.ts
  67. 15
      src/utils/zod.ts
  68. 53
      yarn.lock

@ -29,6 +29,14 @@
"@typescript-eslint/no-explicit-any": ["off"],
"@typescript-eslint/no-non-null-assertion": ["off"],
"@typescript-eslint/no-require-imports": ["warn"],
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"jsx-a11y/alt-text": ["off"]
}
}

@ -16,14 +16,14 @@
"[typescript]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
"source.organizeImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
"source.organizeImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
@ -38,9 +38,4 @@
},
"editor.tabSize": 2,
"editor.detectIndentation": false,
"[typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
}

@ -1,7 +1,7 @@
{
"name": "@hyperlane-xyz/warp-ui-template",
"description": "A web app template for building Hyperlane Warp Route UIs",
"version": "3.6.1",
"version": "3.8.0",
"author": "J M Rossy",
"dependencies": {
"@chakra-ui/next-js": "^2.1.5",
@ -16,9 +16,9 @@
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@headlessui/react": "^1.7.14",
"@hyperlane-xyz/sdk": "^3.6.1",
"@hyperlane-xyz/utils": "^3.6.1",
"@hyperlane-xyz/widgets": "^3.1.4",
"@hyperlane-xyz/sdk": "^3.8.0",
"@hyperlane-xyz/utils": "^3.8.0",
"@hyperlane-xyz/widgets": "^3.8.0",
"@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6",
"@rainbow-me/rainbowkit": "1.3.0",
"@sentry/nextjs": "^7.93.0",
@ -78,13 +78,12 @@
},
"scripts": {
"clean": "rm -rf dist cache .next",
"dev": "yarn build:configs && next dev",
"build": "yarn build:configs && next build",
"build:configs": "./src/scripts/buildConfigs/build.sh",
"dev": "next dev",
"build": "next build",
"typecheck": "tsc",
"lint": "next lint",
"start": "next start",
"test": "yarn build:configs && jest",
"test": "jest --passWithNoTests",
"prettier": "prettier --write ./src"
},
"types": "dist/src/index.d.ts",
@ -94,6 +93,7 @@
"cosmjs-types": "0.9",
"ethers": "^5.7",
"lit-html": "2.8.0",
"react-fast-compare": "^3.2",
"viem": "1.20.0",
"zustand": "^4.4"
}

@ -1,9 +1,9 @@
import { memo } from 'react';
function _SmallSpinner() {
function _SmallSpinner({ className }: { className?: string }) {
return (
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-black"
className={`animate-spin h-5 w-5 text-black ${className}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"

@ -3,24 +3,24 @@ import { useCallback } from 'react';
import { ProtocolType } from '@hyperlane-xyz/utils';
import { tryGetProtocolType } from '../../features/caip/chains';
import { tryGetChainProtocol } from '../../features/chains/utils';
import { useAccountForChain, useConnectFns } from '../../features/wallet/hooks/multiProtocol';
import { useTimeout } from '../../utils/timeout';
import { SolidButton } from './SolidButton';
interface Props {
chainCaip2Id: ChainCaip2Id;
chainName: ChainName;
text: string;
classes?: string;
}
export function ConnectAwareSubmitButton<FormValues = any>({ chainCaip2Id, text, classes }: Props) {
const protocol = tryGetProtocolType(chainCaip2Id) || ProtocolType.Ethereum;
export function ConnectAwareSubmitButton<FormValues = any>({ chainName, text, classes }: Props) {
const protocol = tryGetChainProtocol(chainName) || ProtocolType.Ethereum;
const connectFns = useConnectFns();
const connectFn = connectFns[protocol];
const account = useAccountForChain(chainCaip2Id);
const account = useAccountForChain(chainName);
const isAccountReady = account?.isReady;
const { errors, setErrors, touched, setTouched } = useFormikContext<FormValues>();

@ -1,42 +1,29 @@
import Image from 'next/image';
import { ComponentProps, useMemo } from 'react';
import { isNumeric } from '@hyperlane-xyz/utils';
import { ChainLogo as ChainLogoInner } from '@hyperlane-xyz/widgets';
import { parseCaip2Id } from '../../features/caip/chains';
import { getChainDisplayName } from '../../features/chains/utils';
import { getMultiProvider } from '../../features/multiProvider';
import { logger } from '../../utils/logger';
import { getChainDisplayName, tryGetChainMetadata } from '../../features/chains/utils';
type Props = Omit<ComponentProps<typeof ChainLogoInner>, 'chainId' | 'chainName'> & {
chainCaip2Id?: ChainCaip2Id;
};
export function ChainLogo(props: ComponentProps<typeof ChainLogoInner>) {
const { chainName, ...rest } = props;
const { chainId, chainDisplayName, icon } = useMemo(() => {
if (!chainName) return {};
const chainDisplayName = getChainDisplayName(chainName);
const chainMetadata = tryGetChainMetadata(chainName);
const chainId = chainMetadata?.chainId;
const logoUri = chainMetadata?.logoURI;
const icon = logoUri
? (props: { width: number; height: number; title?: string }) => (
<Image src={logoUri} alt="" {...props} />
)
: undefined;
return {
chainId,
chainDisplayName,
icon,
};
}, [chainName]);
export function ChainLogo(props: Props) {
const { chainCaip2Id, ...rest } = props;
const { chainId, chainName, icon } = useMemo(() => {
if (!chainCaip2Id) return {};
try {
const { reference } = parseCaip2Id(chainCaip2Id);
const chainId = isNumeric(reference) ? parseInt(reference, 10) : undefined;
const chainName = getChainDisplayName(chainCaip2Id);
const logoUri = getMultiProvider().tryGetChainMetadata(reference)?.logoURI;
const icon = logoUri
? (props: { width: number; height: number; title?: string }) => (
<Image src={logoUri} alt="" {...props} />
)
: undefined;
return {
chainId,
chainName,
icon,
};
} catch (error) {
logger.error('Failed to parse caip2 id', error);
return {};
}
}, [chainCaip2Id]);
return <ChainLogoInner {...rest} chainId={chainId} chainName={chainName} icon={icon} />;
return <ChainLogoInner {...rest} chainId={chainId} chainName={chainDisplayName} icon={icon} />;
}

@ -1,15 +1,14 @@
import Image from 'next/image';
import { memo } from 'react';
import { IToken } from '@hyperlane-xyz/sdk';
import { Circle } from '@hyperlane-xyz/widgets';
import { getTokenAddress } from '../../features/caip/tokens';
import { TokenMetadata } from '../../features/tokens/types';
import { isValidUrl } from '../../utils/url';
import { ErrorBoundary } from '../errors/ErrorBoundary';
interface Props {
token?: TokenMetadata;
token?: IToken | null;
size?: number;
}
@ -20,9 +19,7 @@ function _TokenIcon({ token, size = 32 }: Props) {
const fontSize = Math.floor(size / 2);
const bgColorSeed =
token && !imageSrc
? (Buffer.from(getTokenAddress(token.tokenCaip19Id)).at(0) || 0) % 5
: undefined;
token && !imageSrc ? (Buffer.from(token.addressOrDenom).at(0) || 0) % 5 : undefined;
return (
<Circle size={size} bgColorSeed={bgColorSeed} title={title}>

@ -1,11 +1,10 @@
import { useMemo } from 'react';
import { toast } from 'react-toastify';
import { parseCaip2Id } from '../../features/caip/chains';
import { getMultiProvider } from '../../features/multiProvider';
import { getMultiProvider } from '../../context/context';
export function toastTxSuccess(msg: string, txHash: string, chainCaip2Id: ChainCaip2Id) {
toast.success(<TxSuccessToast msg={msg} txHash={txHash} chainCaip2Id={chainCaip2Id} />, {
export function toastTxSuccess(msg: string, txHash: string, chain: ChainName) {
toast.success(<TxSuccessToast msg={msg} txHash={txHash} chain={chain} />, {
autoClose: 12000,
});
}
@ -13,16 +12,15 @@ export function toastTxSuccess(msg: string, txHash: string, chainCaip2Id: ChainC
export function TxSuccessToast({
msg,
txHash,
chainCaip2Id,
chain,
}: {
msg: string;
txHash: string;
chainCaip2Id: ChainCaip2Id;
chain: ChainName;
}) {
const url = useMemo(() => {
const { reference } = parseCaip2Id(chainCaip2Id);
return getMultiProvider().tryGetExplorerTxUrl(reference, { hash: txHash });
}, [chainCaip2Id, txHash]);
return getMultiProvider().tryGetExplorerTxUrl(chain, { hash: txHash });
}, [chain, txHash]);
return (
<div>

@ -1,4 +0,0 @@
import { IbcRoute, IbcToWarpRoute } from '../features/routes/types';
// Configs for manually-defined IBC-only routes
export const ibcRoutes: Array<IbcRoute | IbcToWarpRoute> = [];

@ -1,11 +0,0 @@
import { ProtocolType } from '@hyperlane-xyz/utils';
// IGP Quote overrides can be set here
// If specified, this value will be used instead of querying the token adapter
// Protocol to value | map<chainId,value>
export const DEFAULT_IGP_QUOTES: Partial<
Record<ProtocolType, string | Record<string | number, string>>
> = {
[ProtocolType.Sealevel]: '10000',
[ProtocolType.Cosmos]: '270000',
};

@ -1 +1,4 @@
[]
{
"tokens": [],
"options": {}
}

@ -1,30 +1,9 @@
import { WarpTokenConfig } from '../features/tokens/types';
import { WarpCoreConfig } from '@hyperlane-xyz/sdk';
// A list of Warp UI token configs
// Tokens can be defined here, in tokens.json, or in tokens.yaml
// The input here is typically the output of the Hyperlane CLI warp deploy command
export const tokenList: WarpTokenConfig = [
// Example collateral token for an EVM chain
{
type: 'collateral',
chainId: 5,
address: '0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
hypCollateralAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
name: 'Weth',
symbol: 'WETH',
decimals: 18,
logoURI: '/logos/weth.png', // See public/logos/
},
// Example NFT (ERC721) token for an EVM chain
{
chainId: 5,
name: 'Test721',
symbol: 'TEST721',
decimals: 0,
type: 'collateral',
address: '0x77566D540d1E207dFf8DA205ed78750F9a1e7c55',
hypCollateralAddress: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
isNft: true,
},
];
export const tokenConfigs: WarpCoreConfig = {
tokens: [],
options: {},
};

@ -1,14 +1,24 @@
# A list of Warp UI token configs
# Tokens can be defined here, in tokens.json, or in tokens.ts
# A list of Warp UI token configs and other options for the WarpCore
# Configs can be defined here, in tokens.json, or in tokens.ts
# The input here is typically the output of the Hyperlane CLI warp deploy command
---
# Replace this [] with your token list
[]
# Example using a native token:
# - type: native
# chainId: 11155111
# name: 'Ether'
# symbol: 'ETH'
# decimals: 18
# hypNativeAddress: '0xEa44A29da87B5464774978e6A4F4072A4c048949'
# logoURI: '/logos/weth.png'
tokens:
# Eth Mainnet HypNative token
- chainName: sepolia
standard: EvmHypNative
decimals: 18
symbol: ETH
name: Ether
addressOrDenom: '0x767C51a91CC9dEF2F24C35c340649411D6390320'
logoURI: '/logos/weth.png'
connections:
- { token: ethereum|alfajores|0x8bF6Ca2Dca1DF703Cb9144cef6A4d86abA7776C4 }
- chainName: alfajores
standard: EvmHypSynthetic
decimals: 18
symbol: ETH
name: Ether
addressOrDenom: '0x8bF6Ca2Dca1DF703Cb9144cef6A4d86abA7776C4'
logoURI: '/logos/weth.png'
connections:
- { token: ethereum|sepolia|0x767C51a91CC9dEF2F24C35c340649411D6390320 }

@ -1,4 +0,0 @@
### About
This folder contains pre-validated and processed configs for the the chains, tokens, and routes.
The contents are auto-generated by the `yarn build:configs` command. Changes will be overridden on new builds.

@ -1,22 +1,18 @@
import path from 'path';
import { z } from 'zod';
import { ChainMap, ChainMetadata, ChainMetadataSchema, chainMetadata } from '@hyperlane-xyz/sdk';
import ChainsJson from '../../consts/chains.json';
import { chains as ChainsTS } from '../../consts/chains.ts';
import { cosmosDefaultChain } from '../../features/chains/cosmosDefault';
import { logger } from '../../utils/logger';
import { readYaml } from './utils';
import ChainsJson from '../consts/chains.json';
import { chains as ChainsTS } from '../consts/chains.ts';
import ChainsYaml from '../consts/chains.yaml';
import { cosmosDefaultChain } from '../features/chains/cosmosDefault';
import { logger } from '../utils/logger';
export const ChainConfigSchema = z.record(
ChainMetadataSchema.and(z.object({ mailbox: z.string().optional() })),
);
export function getProcessedChainConfigs() {
const ChainsYaml = readYaml(path.resolve(__dirname, '../../consts/chains.yaml'));
export function getChainConfigs() {
// Chains must include a cosmos chain or CosmosKit throws errors
const result = ChainConfigSchema.safeParse({
cosmoshub: cosmosDefaultChain,

@ -1,29 +1,27 @@
import { ChainMap, ChainMetadata, MultiProtocolProvider } from '@hyperlane-xyz/sdk';
import {
ChainMap,
ChainMetadata,
IToken,
MultiProtocolProvider,
Token,
WarpCore,
} from '@hyperlane-xyz/sdk';
import { isNullish } from '@hyperlane-xyz/utils';
import type { RoutesMap } from '../features/routes/types';
import type { TokenMetadata } from '../features/tokens/types';
import Chains from './_chains.json';
import Routes from './_routes.json';
import Tokens from './_tokens.json';
import { getChainConfigs } from './chains';
import { getWarpCoreConfig } from './tokens';
export interface WarpContext {
chains: ChainMap<ChainMetadata & { mailbox?: Address }>;
tokens: TokenMetadata[];
routes: RoutesMap;
multiProvider: MultiProtocolProvider<{ mailbox?: Address }>;
warpCore: WarpCore;
}
let warpContext: WarpContext;
export function getWarpContext() {
if (!warpContext) {
warpContext = {
chains: Chains as any,
tokens: Tokens as any,
routes: Routes as any,
multiProvider: new MultiProtocolProvider<{ mailbox?: Address }>(Chains as any),
};
warpContext = initWarpContext();
}
return warpContext;
}
@ -31,3 +29,36 @@ export function getWarpContext() {
export function setWarpContext(context: WarpContext) {
warpContext = context;
}
export function initWarpContext() {
const chains = getChainConfigs();
const multiProvider = new MultiProtocolProvider<{ mailbox?: Address }>(chains);
const coreConfig = getWarpCoreConfig();
const warpCore = WarpCore.FromConfig(multiProvider, coreConfig);
return { chains, multiProvider, warpCore };
}
export function getMultiProvider() {
return getWarpContext().multiProvider;
}
export function getWarpCore() {
return getWarpContext().warpCore;
}
export function getTokens() {
return getWarpCore().tokens;
}
export function getTokenByIndex(tokenIndex?: number) {
const tokens = getTokens();
if (isNullish(tokenIndex) || tokenIndex >= tokens.length) return undefined;
return tokens[tokenIndex];
}
export function getIndexForToken(token?: IToken): number | undefined {
if (!token) return undefined;
const index = getTokens().indexOf(token as Token);
if (index >= 0) return index;
else return undefined;
}

@ -0,0 +1,19 @@
import { WarpCoreConfig, WarpCoreConfigSchema } from '@hyperlane-xyz/sdk';
import TokensJson from '../consts/tokens.json';
import { tokenConfigs as TokensTS } from '../consts/tokens.ts';
import TokensYaml from '../consts/tokens.yaml';
import { validateZodResult } from '../utils/zod.ts';
export function getWarpCoreConfig(): WarpCoreConfig {
const resultJson = WarpCoreConfigSchema.safeParse(TokensJson);
const configJson = validateZodResult(resultJson, 'warp core json config');
const resultYaml = WarpCoreConfigSchema.safeParse(TokensYaml);
const configYaml = validateZodResult(resultYaml, 'warp core yaml config');
const resultTs = WarpCoreConfigSchema.safeParse(TokensTS);
const configTs = validateZodResult(resultTs, 'warp core typescript config');
const tokens = [...configTs.tokens, ...configJson.tokens, ...configYaml.tokens];
const options = { ...configTs.options, ...configJson.options, ...configYaml.options };
return { tokens, options };
}

@ -1,67 +0,0 @@
import { ProtocolType } from '@hyperlane-xyz/utils';
import { logger } from '../../utils/logger';
// Based mostly on https://chainagnostic.org/CAIPs/caip-2
// But uses different naming for the protocol
export function getCaip2Id(protocol: ProtocolType, reference: string | number): ChainCaip2Id {
if (!Object.values(ProtocolType).includes(protocol)) {
throw new Error(`Invalid chain environment: ${protocol}`);
}
if (
([ProtocolType.Ethereum, ProtocolType.Sealevel].includes(protocol) &&
(typeof reference !== 'number' || reference <= 0)) ||
(protocol === ProtocolType.Cosmos && typeof reference !== 'string')
) {
throw new Error(`Invalid chain reference: ${reference}`);
}
return `${protocol}:${reference}`;
}
export function parseCaip2Id(id: ChainCaip2Id) {
const [_protocol, reference] = id.split(':');
const protocol = _protocol as ProtocolType;
if (!Object.values(ProtocolType).includes(protocol)) {
throw new Error(`Invalid chain protocol type: ${id}`);
}
if (!reference) {
throw new Error(`No reference found in caip2 id: ${id}`);
}
return { protocol, reference };
}
export function tryParseCaip2Id(id?: ChainCaip2Id) {
if (!id) return undefined;
try {
return parseCaip2Id(id);
} catch (err) {
logger.error(`Error parsing caip2 id ${id}`, err);
return undefined;
}
}
export function getProtocolType(id: ChainCaip2Id) {
const { protocol } = parseCaip2Id(id);
return protocol;
}
export function tryGetProtocolType(id?: ChainCaip2Id) {
return tryParseCaip2Id(id)?.protocol;
}
export function getChainReference(id: ChainCaip2Id) {
const { reference } = parseCaip2Id(id);
return reference;
}
export function tryGetChainReference(id?: ChainCaip2Id) {
return tryParseCaip2Id(id)?.reference;
}
export function getEthereumChainId(id: ChainCaip2Id): number {
const { protocol, reference } = parseCaip2Id(id);
if (protocol !== ProtocolType.Ethereum) {
throw new Error(`Protocol type must be ethereum: ${id}`);
}
return parseInt(reference, 10);
}

@ -1,128 +0,0 @@
import { ProtocolType, isValidAddress, isZeroishAddress } from '@hyperlane-xyz/utils';
import { COSMOS_ZERO_ADDRESS, EVM_ZERO_ADDRESS, SOL_ZERO_ADDRESS } from '../../consts/values';
import { logger } from '../../utils/logger';
export enum AssetNamespace {
native = 'native',
erc20 = 'erc20',
erc721 = 'erc721',
spl = 'spl', // Solana Program Library standard token
spl2022 = 'spl2022', // Updated SPL version
ibcDenom = 'ibcDenom',
}
// Based mostly on https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-19.md
// But uses simpler asset namespace naming for native tokens
export function getCaip19Id(
chainCaip2Id: ChainCaip2Id,
namespace: AssetNamespace,
address: Address,
tokenId?: string | number,
): TokenCaip19Id {
if (!Object.values(AssetNamespace).includes(namespace)) {
throw new Error(`Invalid asset namespace: ${namespace}`);
}
if (!isValidAddress(address) && !isZeroishAddress(address)) {
throw new Error(`Invalid address: ${address}`);
}
// NOTE: deviation from CAIP-19 spec here by separating token id with : instead of /
// Doing this because cosmos addresses use / all over the place
// The CAIP standard doesn't specify how to handle ibc / token factory addresses
return `${chainCaip2Id}/${namespace}:${address}${tokenId ? `:${tokenId}` : ''}`;
}
export function parseCaip19Id(id: TokenCaip19Id) {
const segments = id.split('/');
if (segments.length < 2)
throw new Error(`Invalid caip19 id: ${id}. Must have at least 2 main segments`);
const chainCaip2Id = segments[0] as ChainCaip2Id;
const rest = segments.slice(1).join('/');
const tokenSegments = rest.split(':');
let namespace: AssetNamespace;
let address: Address;
let tokenId: string | undefined;
if (tokenSegments.length == 2) {
[namespace, address] = tokenSegments as [AssetNamespace, Address];
} else if (tokenSegments.length == 3) {
// NOTE: deviation from CAIP-19 spec here by separating token id with : instead of /
// Doing this because cosmos addresses use / all over the place
// The CAIP standard doesn't specify how to handle ibc / token factory addresses
[namespace, address, tokenId] = tokenSegments as [AssetNamespace, Address, string];
} else {
throw new Error(`Invalid caip19 id: ${id}. Must have 2 or 3 token segment`);
}
if (!chainCaip2Id || !namespace || !address)
throw new Error(`Invalid caip19 id: ${id}. Segment values missing`);
return { chainCaip2Id, namespace, address, tokenId };
}
export function tryParseCaip19Id(id?: TokenCaip19Id) {
if (!id) return undefined;
try {
return parseCaip19Id(id);
} catch (err) {
logger.error(`Error parsing caip2 id ${id}`, err);
return undefined;
}
}
export function getChainIdFromToken(id: TokenCaip19Id): ChainCaip2Id {
return parseCaip19Id(id).chainCaip2Id;
}
export function tryGetChainIdFromToken(id?: TokenCaip19Id): ChainCaip2Id | undefined {
return tryParseCaip19Id(id)?.chainCaip2Id;
}
export function getAssetNamespace(id: TokenCaip19Id): AssetNamespace {
return parseCaip19Id(id).namespace as AssetNamespace;
}
export function getTokenAddress(id: TokenCaip19Id): Address {
return parseCaip19Id(id).address;
}
export function isNativeToken(id: TokenCaip19Id): boolean {
const { namespace } = parseCaip19Id(id);
return namespace === AssetNamespace.native;
}
export function getNativeTokenAddress(protocol: ProtocolType): Address {
if (protocol === ProtocolType.Ethereum) {
return EVM_ZERO_ADDRESS;
} else if (protocol === ProtocolType.Sealevel) {
return SOL_ZERO_ADDRESS;
} else if (protocol === ProtocolType.Cosmos) {
return COSMOS_ZERO_ADDRESS;
} else {
throw new Error(`Unsupported protocol: ${protocol}`);
}
}
export function isNonFungibleToken(id: TokenCaip19Id): boolean {
const { namespace } = parseCaip19Id(id);
return namespace === AssetNamespace.erc721;
}
export function resolveAssetNamespace(
protocol: ProtocolType,
isNative?: boolean,
isNft?: boolean,
isSpl2022?: boolean,
) {
if (isNative) return AssetNamespace.native;
switch (protocol) {
case ProtocolType.Ethereum:
return isNft ? AssetNamespace.erc721 : AssetNamespace.erc20;
case ProtocolType.Sealevel:
return isSpl2022 ? AssetNamespace.spl2022 : AssetNamespace.spl;
case ProtocolType.Cosmos:
return AssetNamespace.ibcDenom;
default:
throw new Error(`Unsupported protocol: ${protocol}`);
}
}

@ -12,21 +12,21 @@ import { getChainDisplayName } from './utils';
type Props = {
name: string;
label: string;
chainCaip2Ids: ChainCaip2Id[];
onChange?: (id: ChainCaip2Id) => void;
chains: ChainName[];
onChange?: (id: ChainName) => void;
disabled?: boolean;
};
export function ChainSelectField({ name, label, chainCaip2Ids, onChange, disabled }: Props) {
const [field, , helpers] = useField<ChainCaip2Id>(name);
export function ChainSelectField({ name, label, chains, onChange, disabled }: Props) {
const [field, , helpers] = useField<ChainName>(name);
const { setFieldValue } = useFormikContext<TransferFormValues>();
const handleChange = (newChainId: ChainCaip2Id) => {
const handleChange = (newChainId: ChainName) => {
helpers.setValue(newChainId);
// Reset other fields on chain change
setFieldValue('recipientAddress', '');
setFieldValue('recipient', '');
setFieldValue('amount', '');
setFieldValue('tokenCaip19Id', '');
setFieldValue('tokenIndex', undefined);
if (onChange) onChange(newChainId);
};
@ -40,7 +40,7 @@ export function ChainSelectField({ name, label, chainCaip2Ids, onChange, disable
<div className="flex flex-col items-center">
<div className="flex flex-col items-center justify-center rounded-full bg-gray-100 h-[5.5rem] w-[5.5rem] p-1.5">
<div className="flex items-end h-11">
<ChainLogo chainCaip2Id={field.value} size={34} />
<ChainLogo chainName={field.value} size={34} />
</div>
<label htmlFor={name} className="mt-2 mb-1 text-sm text-gray-500 uppercase">
{label}
@ -53,7 +53,7 @@ export function ChainSelectField({ name, label, chainCaip2Ids, onChange, disable
onClick={onClick}
>
<div className="flex items-center">
<ChainLogo chainCaip2Id={field.value} size={14} />
<ChainLogo chainName={field.value} size={14} />
<span className="ml-2">{getChainDisplayName(field.value, true)}</span>
</div>
<Image src={ChevronIcon} width={12} height={8} alt="" />
@ -61,7 +61,7 @@ export function ChainSelectField({ name, label, chainCaip2Ids, onChange, disable
<ChainSelectListModal
isOpen={isModalOpen}
close={() => setIsModalOpen(false)}
chainCaip2Ids={chainCaip2Ids}
chains={chains}
onSelect={handleChange}
/>
</div>

@ -6,17 +6,17 @@ import { getChainDisplayName } from './utils';
export function ChainSelectListModal({
isOpen,
close,
chainCaip2Ids,
chains,
onSelect,
}: {
isOpen: boolean;
close: () => void;
chainCaip2Ids: ChainCaip2Id[];
onSelect: (chainCaip2Id: ChainCaip2Id) => void;
chains: ChainName[];
onSelect: (chain: ChainName) => void;
}) {
const onSelectChain = (chainCaip2Id: ChainCaip2Id) => {
const onSelectChain = (chain: ChainName) => {
return () => {
onSelect(chainCaip2Id);
onSelect(chain);
close();
};
};
@ -24,13 +24,13 @@ export function ChainSelectListModal({
return (
<Modal isOpen={isOpen} title="Select Chain" close={close}>
<div className="mt-2 flex flex-col space-y-1">
{chainCaip2Ids.map((c) => (
{chains.map((c) => (
<button
key={c}
className="py-1.5 px-2 text-sm flex items-center rounded hover:bg-gray-100 active:bg-gray-200 transition-all duration-200"
onClick={onSelectChain(c)}
>
<ChainLogo chainCaip2Id={c} size={16} background={false} />
<ChainLogo chainName={c} size={16} background={false} />
<span className="ml-2">{getChainDisplayName(c, true)}</span>
</button>
))}

@ -16,6 +16,7 @@ export const cosmosDefaultChain: ChainMetadata = {
name: 'Atom',
symbol: 'ATOM',
decimals: 6,
denom: 'uatom',
},
logoURI: '/logos/cosmos.svg',
};

@ -57,7 +57,6 @@ export function getCosmosKitConfig(): { chains: CosmosChain[]; assets: AssetList
],
},
}));
// TODO cosmos cleanup here
const assets = cosmosChains.map((c) => {
if (!c.nativeToken) throw new Error(`Missing native token for ${c.name}`);
return {
@ -66,19 +65,11 @@ export function getCosmosKitConfig(): { chains: CosmosChain[]; assets: AssetList
{
description: `The native token of ${c.displayName || c.name} chain.`,
denom_units: [
// {
// denom: `u${c.nativeToken.symbol}`,
// exponent: 0,
// },
{
denom: 'token',
exponent: c.nativeToken.decimals,
},
],
// base: `u${c.nativeToken.symbol}`,
// name: c.nativeToken.name,
// display: c.nativeToken.symbol,
// symbol: c.nativeToken.symbol,
base: 'token',
name: 'token',
display: 'token',

@ -1,25 +1,22 @@
import { chainIdToMetadata } from '@hyperlane-xyz/sdk';
import { ChainNameOrId, chainMetadata } from '@hyperlane-xyz/sdk';
import { ProtocolType, toTitleCase } from '@hyperlane-xyz/utils';
import { parseCaip2Id } from '../caip/chains';
import { getMultiProvider } from '../multiProvider';
import { getMultiProvider } from '../../context/context';
export function getChainDisplayName(id: ChainCaip2Id, shortName = false) {
if (!id) return 'Unknown';
const { reference } = parseCaip2Id(id);
const metadata = getMultiProvider().tryGetChainMetadata(reference || 0);
export function getChainDisplayName(chain: ChainName, shortName = false) {
if (!chain) return 'Unknown';
const metadata = tryGetChainMetadata(chain);
if (!metadata) return 'Unknown';
const displayName = shortName ? metadata.displayNameShort : metadata.displayName;
return displayName || metadata.displayName || toTitleCase(metadata.name);
}
export function isPermissionlessChain(id: ChainCaip2Id) {
if (!id) return true;
const { protocol, reference } = parseCaip2Id(id);
return protocol !== ProtocolType.Ethereum || !chainIdToMetadata[reference];
export function isPermissionlessChain(chain: ChainName) {
if (!chain) return true;
return getChainMetadata(chain).protocol === ProtocolType.Ethereum || !chainMetadata[chain];
}
export function hasPermissionlessChain(ids: ChainCaip2Id[]) {
export function hasPermissionlessChain(ids: ChainName[]) {
return !ids.every((c) => !isPermissionlessChain(c));
}
@ -30,3 +27,19 @@ export function getChainByRpcEndpoint(endpoint?: string) {
(m) => !!m.rpcUrls.find((rpc) => rpc.http.toLowerCase().includes(endpoint.toLowerCase())),
);
}
export function tryGetChainMetadata(chain: ChainNameOrId) {
return getMultiProvider().tryGetChainMetadata(chain);
}
export function getChainMetadata(chain: ChainNameOrId) {
return getMultiProvider().getChainMetadata(chain);
}
export function tryGetChainProtocol(chain: ChainNameOrId) {
return tryGetChainMetadata(chain)?.protocol;
}
export function getChainProtocol(chain: ChainNameOrId) {
return getChainMetadata(chain).protocol;
}

@ -1,32 +0,0 @@
import { ProtocolType } from '@hyperlane-xyz/utils';
import { getWarpContext } from '../context/context';
import { parseCaip2Id } from './caip/chains';
export function getMultiProvider() {
return getWarpContext().multiProvider;
}
export function getEvmProvider(id: ChainCaip2Id) {
const { reference, protocol } = parseCaip2Id(id);
if (protocol !== ProtocolType.Ethereum) throw new Error('Expected EVM chain for provider');
// TODO viem
return getMultiProvider().getEthersV5Provider(reference);
}
export function getSealevelProvider(id: ChainCaip2Id) {
const { reference, protocol } = parseCaip2Id(id);
if (protocol !== ProtocolType.Sealevel) throw new Error('Expected Sealevel chain for provider');
return getMultiProvider().getSolanaWeb3Provider(reference);
}
export function getCosmJsWasmProvider(id: ChainCaip2Id) {
const { reference, protocol } = parseCaip2Id(id);
if (protocol !== ProtocolType.Cosmos) throw new Error('Expected Cosmos chain for provider');
return getMultiProvider().getCosmJsWasmProvider(reference);
}
export function getChainMetadata(id: ChainCaip2Id) {
return getMultiProvider().getChainMetadata(parseCaip2Id(id).reference);
}

@ -1,19 +0,0 @@
import { useMemo } from 'react';
import { getChainIdFromToken } from '../caip/tokens';
import { getTokens } from '../tokens/metadata';
import { RoutesMap } from './types';
export function useRouteChains(tokenRoutes: RoutesMap): ChainCaip2Id[] {
return useMemo(() => {
const allCaip2Ids = Object.keys(tokenRoutes) as ChainCaip2Id[];
const collateralCaip2Ids = getTokens().map((t) => getChainIdFromToken(t.tokenCaip19Id));
return allCaip2Ids.sort((c1, c2) => {
// Surface collateral chains first
if (collateralCaip2Ids.includes(c1) && !collateralCaip2Ids.includes(c2)) return -1;
else if (!collateralCaip2Ids.includes(c1) && collateralCaip2Ids.includes(c2)) return 1;
else return c1 > c2 ? 1 : -1;
});
}, [tokenRoutes]);
}

@ -1,54 +0,0 @@
export enum RouteType {
CollateralToCollateral = 'collateralToCollateral',
CollateralToSynthetic = 'collateralToSynthetic',
SyntheticToSynthetic = 'syntheticToSynthetic',
SyntheticToCollateral = 'syntheticToCollateral',
IbcNativeToIbcNative = 'ibcNativeToIbcNative',
IbcNativeToHypSynthetic = 'ibcNativeToHypSynthetic',
}
interface BaseRoute {
type: RouteType;
// The underlying 'collateralized' token:
baseTokenCaip19Id: TokenCaip19Id;
originCaip2Id: ChainCaip2Id;
originDecimals: number;
destCaip2Id: ChainCaip2Id;
destDecimals: number;
// The underlying token on the destination chain
// Only set for CollateralToCollateral routes (b.c. sealevel needs it)
destTokenCaip19Id?: TokenCaip19Id;
}
export interface WarpRoute extends BaseRoute {
type:
| RouteType.CollateralToCollateral
| RouteType.CollateralToSynthetic
| RouteType.SyntheticToCollateral
| RouteType.SyntheticToSynthetic;
baseRouterAddress: Address;
originRouterAddress: Address;
destRouterAddress: Address;
}
interface BaseIbcRoute extends BaseRoute {
originIbcDenom: string;
sourcePort: string;
sourceChannel: string;
derivedIbcDenom: string;
}
export interface IbcRoute extends BaseIbcRoute {
type: RouteType.IbcNativeToIbcNative;
}
export interface IbcToWarpRoute extends BaseIbcRoute {
type: RouteType.IbcNativeToHypSynthetic;
intermediateCaip2Id: ChainCaip2Id;
intermediateRouterAddress: Address;
destRouterAddress: Address;
}
export type Route = WarpRoute | IbcRoute | IbcToWarpRoute;
export type RoutesMap = Record<ChainCaip2Id, Record<ChainCaip2Id, Route[]>>;

@ -1,83 +0,0 @@
import { isNativeToken } from '../caip/tokens';
import { IbcRoute, IbcToWarpRoute, Route, RouteType, RoutesMap, WarpRoute } from './types';
export function getTokenRoutes(
originCaip2Id: ChainCaip2Id,
destinationCaip2Id: ChainCaip2Id,
tokenRoutes: RoutesMap,
): Route[] {
return tokenRoutes[originCaip2Id]?.[destinationCaip2Id] || [];
}
export function getTokenRoute(
originCaip2Id: ChainCaip2Id,
destinationCaip2Id: ChainCaip2Id,
tokenCaip19Id: TokenCaip19Id,
tokenRoutes: RoutesMap,
): Route | undefined {
if (!tokenCaip19Id) return undefined;
return getTokenRoutes(originCaip2Id, destinationCaip2Id, tokenRoutes).find(
(r) => r.baseTokenCaip19Id === tokenCaip19Id,
);
}
export function hasTokenRoute(
originCaip2Id: ChainCaip2Id,
destinationCaip2Id: ChainCaip2Id,
tokenCaip19Id: TokenCaip19Id,
tokenRoutes: RoutesMap,
): boolean {
return !!getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes);
}
export function isRouteToCollateral(route: Route) {
return (
route.type === RouteType.CollateralToCollateral ||
route.type === RouteType.SyntheticToCollateral
);
}
export function isRouteFromCollateral(route: Route) {
return (
route.type === RouteType.CollateralToCollateral ||
route.type === RouteType.CollateralToSynthetic
);
}
export function isRouteToSynthetic(route: Route) {
return (
route.type === RouteType.CollateralToSynthetic || route.type === RouteType.SyntheticToSynthetic
);
}
export function isRouteFromSynthetic(route: Route) {
return (
route.type === RouteType.SyntheticToCollateral || route.type === RouteType.SyntheticToSynthetic
);
}
export function isRouteFromNative(route: Route) {
return isRouteFromCollateral(route) && isNativeToken(route.baseTokenCaip19Id);
}
export function isWarpRoute(route: Route): route is WarpRoute {
return !isIbcRoute(route);
}
export function isIbcRoute(route: Route): route is IbcRoute | IbcToWarpRoute {
return (
route.type === RouteType.IbcNativeToIbcNative ||
route.type === RouteType.IbcNativeToHypSynthetic
);
}
// Differs from isIbcRoute above in that it it's only true for routes that
// Never interact with Hyperlane routers at all
export function isIbcOnlyRoute(route: Route): route is IbcRoute {
return route.type === RouteType.IbcNativeToIbcNative;
}
export function isIbcToWarpRoute(route: Route): route is IbcToWarpRoute {
return route.type === RouteType.IbcNativeToHypSynthetic;
}

@ -1,10 +1,10 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { FinalTransferStatuses, IgpQuote, TransferContext, TransferStatus } from './transfer/types';
import { FinalTransferStatuses, TransferContext, TransferStatus } from './transfer/types';
// Increment this when persist state has breaking changes
const PERSIST_STATE_VERSION = 1;
const PERSIST_STATE_VERSION = 2;
// Keeping everything here for now as state is simple
// Will refactor into slices as necessary
@ -20,17 +20,6 @@ export interface AppState {
failUnconfirmedTransfers: () => void;
transferLoading: boolean;
setTransferLoading: (isLoading: boolean) => void;
balances: {
senderTokenBalance: string;
senderNativeBalance: string;
senderNftIds: string[] | null; // null means unknown
isSenderNftOwner: boolean | null;
};
setSenderBalances: (tokenBalance: string, nativeBalance: string) => void;
setSenderNftIds: (ids: string[] | null) => void;
setIsSenderNftOwner: (isOwner: boolean | null) => void;
igpQuote: IgpQuote | null;
setIgpQuote: (quote: IgpQuote | null) => void;
}
export const useStore = create<AppState>()(
@ -66,27 +55,6 @@ export const useStore = create<AppState>()(
setTransferLoading: (isLoading) => {
set(() => ({ transferLoading: isLoading }));
},
balances: {
senderTokenBalance: '0',
senderNativeBalance: '0',
senderNftIds: null,
isSenderNftOwner: false,
},
setSenderBalances: (senderTokenBalance, senderNativeBalance) => {
set((state) => ({
balances: { ...state.balances, senderTokenBalance, senderNativeBalance },
}));
},
setSenderNftIds: (senderNftIds) => {
set((state) => ({ balances: { ...state.balances, senderNftIds } }));
},
setIsSenderNftOwner: (isSenderNftOwner) => {
set((state) => ({ balances: { ...state.balances, isSenderNftOwner } }));
},
igpQuote: null,
setIgpQuote: (quote) => {
set(() => ({ igpQuote: quote }));
},
}),
{
name: 'app-state',

@ -1,249 +0,0 @@
import {
ChainName,
CosmNativeTokenAdapter,
CwHypCollateralAdapter,
CwHypNativeAdapter,
CwHypSyntheticAdapter,
CwNativeTokenAdapter,
CwTokenAdapter,
EvmHypCollateralAdapter,
EvmHypSyntheticAdapter,
EvmNativeTokenAdapter,
EvmTokenAdapter,
IHypTokenAdapter,
ITokenAdapter,
MultiProtocolProvider,
SealevelHypCollateralAdapter,
SealevelHypNativeAdapter,
SealevelHypSyntheticAdapter,
SealevelNativeTokenAdapter,
SealevelTokenAdapter,
} from '@hyperlane-xyz/sdk';
import { Address, ProtocolType, convertToProtocolAddress } from '@hyperlane-xyz/utils';
import { parseCaip2Id } from '../caip/chains';
import { AssetNamespace, getChainIdFromToken, isNativeToken, parseCaip19Id } from '../caip/tokens';
import { getMultiProvider } from '../multiProvider';
import { Route } from '../routes/types';
import {
isIbcRoute,
isIbcToWarpRoute,
isRouteFromCollateral,
isRouteFromSynthetic,
isRouteToCollateral,
isRouteToSynthetic,
isWarpRoute,
} from '../routes/utils';
import { getToken } from './metadata';
export class AdapterFactory {
static NativeAdapterFromChain(
chainCaip2Id: ChainCaip2Id,
useCosmNative = false,
adapterProperties?: any,
): ITokenAdapter {
const { protocol, reference: chainId } = parseCaip2Id(chainCaip2Id);
const multiProvider = getMultiProvider();
const chainName = multiProvider.getChainMetadata(chainId).name;
if (protocol == ProtocolType.Ethereum) {
return new EvmNativeTokenAdapter(chainName, multiProvider, {});
} else if (protocol === ProtocolType.Sealevel) {
return new SealevelNativeTokenAdapter(chainName, multiProvider, {});
} else if (protocol === ProtocolType.Cosmos) {
return useCosmNative
? new CosmNativeTokenAdapter(chainName, multiProvider, {}, adapterProperties)
: new CwNativeTokenAdapter(chainName, multiProvider, {});
} else {
throw new Error(`Unsupported protocol: ${protocol}`);
}
}
static NativeAdapterFromRoute(route: Route, source: 'origin' | 'destination'): ITokenAdapter {
let useCosmNative = false;
let adapterProperties: any = undefined;
if (isIbcRoute(route)) {
useCosmNative = true;
adapterProperties = {
ibcDenom: source === 'origin' ? route.originIbcDenom : route.derivedIbcDenom,
sourcePort: route.sourcePort,
sourceChannel: route.sourceChannel,
};
}
return AdapterFactory.NativeAdapterFromChain(
source === 'origin' ? route.originCaip2Id : route.destCaip2Id,
useCosmNative,
adapterProperties,
);
}
static TokenAdapterFromAddress(tokenCaip19Id: TokenCaip19Id): ITokenAdapter {
const { address, chainCaip2Id } = parseCaip19Id(tokenCaip19Id);
const { protocol, reference: chainId } = parseCaip2Id(chainCaip2Id);
const multiProvider = getMultiProvider();
const chainName = multiProvider.getChainMetadata(chainId).name;
const isNative = isNativeToken(tokenCaip19Id);
if (protocol == ProtocolType.Ethereum) {
return isNative
? new EvmNativeTokenAdapter(chainName, multiProvider, {})
: new EvmTokenAdapter(chainName, multiProvider, { token: address });
} else if (protocol === ProtocolType.Sealevel) {
return isNative
? new SealevelNativeTokenAdapter(chainName, multiProvider, {})
: new SealevelTokenAdapter(chainName, multiProvider, { token: address });
} else if (protocol === ProtocolType.Cosmos) {
return isNative
? new CwNativeTokenAdapter(chainName, multiProvider, {})
: new CwTokenAdapter(chainName, multiProvider, { token: address });
} else {
throw new Error(`Unsupported protocol: ${protocol}`);
}
}
static HypCollateralAdapterFromAddress(
baseTokenCaip19Id: TokenCaip19Id,
routerAddress: Address,
): IHypTokenAdapter {
const isNative = isNativeToken(baseTokenCaip19Id);
return AdapterFactory.selectHypAdapter(
getChainIdFromToken(baseTokenCaip19Id),
routerAddress,
baseTokenCaip19Id,
EvmHypCollateralAdapter,
isNative ? SealevelHypNativeAdapter : SealevelHypCollateralAdapter,
isNative ? CwHypNativeAdapter : CwHypCollateralAdapter,
);
}
static HypSyntheticTokenAdapterFromAddress(
baseTokenCaip19Id: TokenCaip19Id,
chainCaip2Id: ChainCaip2Id,
routerAddress: Address,
): IHypTokenAdapter {
return AdapterFactory.selectHypAdapter(
chainCaip2Id,
routerAddress,
baseTokenCaip19Id,
EvmHypSyntheticAdapter,
SealevelHypSyntheticAdapter,
CwHypSyntheticAdapter,
);
}
static HypTokenAdapterFromRouteOrigin(route: Route): IHypTokenAdapter {
if (!isWarpRoute(route)) throw new Error('Route is not a hyp route');
const { type, originCaip2Id, originRouterAddress, baseTokenCaip19Id } = route;
const isNative = isNativeToken(baseTokenCaip19Id);
if (isRouteFromCollateral(route)) {
return AdapterFactory.selectHypAdapter(
originCaip2Id,
originRouterAddress,
baseTokenCaip19Id,
EvmHypCollateralAdapter,
isNative ? SealevelHypNativeAdapter : SealevelHypCollateralAdapter,
isNative ? CwHypNativeAdapter : CwHypCollateralAdapter,
);
} else if (isRouteFromSynthetic(route)) {
return AdapterFactory.selectHypAdapter(
originCaip2Id,
originRouterAddress,
baseTokenCaip19Id,
EvmHypSyntheticAdapter,
SealevelHypSyntheticAdapter,
CwHypSyntheticAdapter,
);
} else {
throw new Error(`Unsupported route type: ${type}`);
}
}
static HypTokenAdapterFromRouteDest(route: Route): IHypTokenAdapter {
if (!isWarpRoute(route) && !isIbcToWarpRoute(route))
throw new Error('Route is not a hyp route');
const { type, destCaip2Id, destRouterAddress, destTokenCaip19Id, baseTokenCaip19Id } = route;
const tokenCaip19Id = destTokenCaip19Id || baseTokenCaip19Id;
const isNative = isNativeToken(baseTokenCaip19Id);
if (isRouteToCollateral(route) || isIbcToWarpRoute(route)) {
return AdapterFactory.selectHypAdapter(
destCaip2Id,
destRouterAddress,
tokenCaip19Id,
EvmHypCollateralAdapter,
isNative ? SealevelHypNativeAdapter : SealevelHypCollateralAdapter,
isNative ? CwHypNativeAdapter : CwHypCollateralAdapter,
);
} else if (isRouteToSynthetic(route)) {
return AdapterFactory.selectHypAdapter(
destCaip2Id,
destRouterAddress,
tokenCaip19Id,
EvmHypSyntheticAdapter,
SealevelHypSyntheticAdapter,
CwHypSyntheticAdapter,
);
} else {
throw new Error(`Unsupported route type: ${type}`);
}
}
protected static selectHypAdapter(
chainCaip2Id: ChainCaip2Id,
routerAddress: Address,
baseTokenCaip19Id: TokenCaip19Id,
EvmAdapter: new (
chainName: ChainName,
mp: MultiProtocolProvider,
addresses: { token: Address },
) => IHypTokenAdapter,
SealevelAdapter: new (
chainName: ChainName,
mp: MultiProtocolProvider,
addresses: { token: Address; warpRouter: Address; mailbox: Address },
isSpl2022?: boolean,
) => IHypTokenAdapter,
CosmosAdapter: new (
chainName: ChainName,
mp: MultiProtocolProvider,
addresses: any,
gasDenom?: string,
) => IHypTokenAdapter,
): IHypTokenAdapter {
const { protocol, reference: chainId } = parseCaip2Id(chainCaip2Id);
const { address: baseTokenAddress, namespace } = parseCaip19Id(baseTokenCaip19Id);
const tokenMetadata = getToken(baseTokenCaip19Id);
if (!tokenMetadata) throw new Error(`Token metadata not found for ${baseTokenCaip19Id}`);
const multiProvider = getMultiProvider();
const { name: chainName, mailbox, bech32Prefix } = multiProvider.getChainMetadata(chainId);
if (protocol == ProtocolType.Ethereum) {
return new EvmAdapter(chainName, multiProvider, {
token: convertToProtocolAddress(routerAddress, protocol),
});
} else if (protocol === ProtocolType.Sealevel) {
if (!mailbox) throw new Error('Mailbox address required for sealevel hyp adapter');
return new SealevelAdapter(
chainName,
multiProvider,
{
token: convertToProtocolAddress(baseTokenAddress, protocol),
warpRouter: convertToProtocolAddress(routerAddress, protocol),
mailbox,
},
namespace === AssetNamespace.spl2022,
);
} else if (protocol === ProtocolType.Cosmos) {
if (!bech32Prefix) throw new Error('Bech32 prefix required for cosmos hyp adapter');
return new CosmosAdapter(
chainName,
multiProvider,
{
token: convertToProtocolAddress(baseTokenAddress, protocol, bech32Prefix),
warpRouter: convertToProtocolAddress(routerAddress, protocol, bech32Prefix),
},
tokenMetadata.igpTokenAddressOrDenom || baseTokenAddress,
);
} else {
throw new Error(`Unsupported protocol: ${protocol}`);
}
}
}

@ -1,65 +1,35 @@
import { useFormikContext } from 'formik';
import { TextField } from '../../components/input/TextField';
import { AssetNamespace, getCaip19Id } from '../caip/tokens';
import { RouteType, RoutesMap } from '../routes/types';
import { getTokenRoute, isWarpRoute } from '../routes/utils';
import { TransferFormValues } from '../transfer/types';
import { useAccountAddressForChain } from '../wallet/hooks/multiProtocol';
import { SelectTokenIdField } from './SelectTokenIdField';
import { useContractSupportsTokenByOwner, useIsSenderNftOwner } from './balances';
export function SelectOrInputTokenIds({
disabled,
tokenRoutes,
}: {
disabled: boolean;
tokenRoutes: RoutesMap;
}) {
// import { useContractSupportsTokenByOwner, useIsSenderNftOwner } from './balances';
export function SelectOrInputTokenIds({ disabled }: { disabled: boolean }) {
const {
values: { originCaip2Id, tokenCaip19Id, destinationCaip2Id },
values: { tokenIndex },
} = useFormikContext<TransferFormValues>();
const route = getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes);
let activeToken = '' as TokenCaip19Id;
if (route?.type === RouteType.CollateralToSynthetic) {
// If the origin is the base chain, use the collateralized token for balance checking
activeToken = tokenCaip19Id;
} else if (route && isWarpRoute(route)) {
// Otherwise, use the synthetic token for balance checking
activeToken = getCaip19Id(
route.originCaip2Id,
AssetNamespace.erc721,
route.originRouterAddress,
);
}
const accountAddress = useAccountAddressForChain(originCaip2Id);
const { isContractAllowToGetTokenIds } = useContractSupportsTokenByOwner(
activeToken,
accountAddress,
);
// const accountAddress = useAccountAddressForChain(origin);
// const { isContractAllowToGetTokenIds } = useContractSupportsTokenByOwner(
// activeToken,
// accountAddress,
// );
const isContractAllowToGetTokenIds = true;
return isContractAllowToGetTokenIds ? (
<SelectTokenIdField name="amount" disabled={disabled} tokenCaip19Id={activeToken} />
<SelectTokenIdField name="amount" disabled={disabled} tokenIndex={tokenIndex} />
) : (
<InputTokenId disabled={disabled} tokenCaip19Id={activeToken} />
<InputTokenId disabled={disabled} tokenIndex={tokenIndex} />
);
}
function InputTokenId({
disabled,
tokenCaip19Id,
}: {
disabled: boolean;
tokenCaip19Id: TokenCaip19Id;
}) {
const {
values: { amount },
} = useFormikContext<TransferFormValues>();
useIsSenderNftOwner(tokenCaip19Id, amount);
function InputTokenId({ disabled }: { disabled: boolean; tokenIndex?: number }) {
// const {
// values: { amount },
// } = useFormikContext<TransferFormValues>();
// useIsSenderNftOwner(token, amount);
return (
<div className="relative w-full">

@ -6,15 +6,13 @@ import { Spinner } from '../../components/animation/Spinner';
import { Modal } from '../../components/layout/Modal';
import ChevronIcon from '../../images/icons/chevron-down.svg';
import { useOriginTokenIdBalance } from './balances';
type Props = {
name: string;
tokenCaip19Id: TokenCaip19Id;
tokenIndex?: number;
disabled?: boolean;
};
export function SelectTokenIdField({ name, tokenCaip19Id, disabled }: Props) {
export function SelectTokenIdField({ name, disabled }: Props) {
const [, , helpers] = useField<number>(name);
const [tokenId, setTokenId] = useState<string | undefined>(undefined);
const handleChange = (newTokenId: string) => {
@ -22,7 +20,8 @@ export function SelectTokenIdField({ name, tokenCaip19Id, disabled }: Props) {
setTokenId(newTokenId);
};
const { isLoading, tokenIds } = useOriginTokenIdBalance(tokenCaip19Id);
const isLoading = false;
const tokenIds = [];
const [isModalOpen, setIsModalOpen] = useState(false);

@ -1,33 +1,28 @@
import Image from 'next/image';
import { useMemo, useState } from 'react';
import { IToken } from '@hyperlane-xyz/sdk';
import { TokenIcon } from '../../components/icons/TokenIcon';
import { TextInput } from '../../components/input/TextField';
import { Modal } from '../../components/layout/Modal';
import { config } from '../../consts/config';
import { getWarpCore } from '../../context/context';
import InfoIcon from '../../images/icons/info-circle.svg';
import { getAssetNamespace, getTokenAddress, isNativeToken } from '../caip/tokens';
import { getChainDisplayName } from '../chains/utils';
import { RoutesMap } from '../routes/types';
import { hasTokenRoute } from '../routes/utils';
import { getTokens } from './metadata';
import { TokenMetadata } from './types';
export function TokenListModal({
isOpen,
close,
onSelect,
originCaip2Id,
destinationCaip2Id,
tokenRoutes,
origin,
destination,
}: {
isOpen: boolean;
close: () => void;
onSelect: (token: TokenMetadata) => void;
originCaip2Id: ChainCaip2Id;
destinationCaip2Id: ChainCaip2Id;
tokenRoutes: RoutesMap;
onSelect: (token: IToken) => void;
origin: ChainName;
destination: ChainName;
}) {
const [search, setSearch] = useState('');
@ -36,7 +31,7 @@ export function TokenListModal({
setSearch('');
};
const onSelectAndClose = (token: TokenMetadata) => {
const onSelectAndClose = (token: IToken) => {
onSelect(token);
onClose();
};
@ -57,9 +52,8 @@ export function TokenListModal({
autoComplete="off"
/>
<TokenList
originCaip2Id={originCaip2Id}
destinationCaip2Id={destinationCaip2Id}
tokenRoutes={tokenRoutes}
origin={origin}
destination={destination}
searchQuery={search}
onSelect={onSelectAndClose}
/>
@ -68,82 +62,74 @@ export function TokenListModal({
}
export function TokenList({
originCaip2Id,
destinationCaip2Id,
tokenRoutes,
origin,
destination,
searchQuery,
onSelect,
}: {
originCaip2Id: ChainCaip2Id;
destinationCaip2Id: ChainCaip2Id;
tokenRoutes: RoutesMap;
origin: ChainName;
destination: ChainName;
searchQuery: string;
onSelect: (token: TokenMetadata) => void;
onSelect: (token: IToken) => void;
}) {
const tokens = useMemo(() => {
const q = searchQuery?.trim().toLowerCase();
const warpCore = getWarpCore();
const multiChainTokens = warpCore.tokens.filter((t) => t.isMultiChainToken());
const tokensWithRoute = warpCore.getTokensForRoute(origin, destination);
return (
getTokens()
.map((t) => {
const hasRoute = hasTokenRoute(
originCaip2Id,
destinationCaip2Id,
t.tokenCaip19Id,
tokenRoutes,
);
return { ...t, disabled: !hasRoute };
})
multiChainTokens
.map((t) => ({
token: t,
disabled: !tokensWithRoute.includes(t),
}))
.sort((a, b) => {
if (a.disabled && !b.disabled) return 1;
else if (!a.disabled && b.disabled) return -1;
else return 0;
})
// Remove duplicates
.filter((t, i, list) => i === list.findIndex((t2) => t2.tokenCaip19Id === t.tokenCaip19Id))
// Filter down to search query
.filter((t) => {
if (!q) return t;
return (
t.name.toLowerCase().includes(q) ||
t.symbol.toLowerCase().includes(q) ||
t.tokenCaip19Id.toLowerCase().includes(q)
t.token.name.toLowerCase().includes(q) ||
t.token.symbol.toLowerCase().includes(q) ||
t.token.addressOrDenom.toLowerCase().includes(q)
);
})
// Hide/show disabled tokens
.filter((t) => (config.showDisabledTokens ? true : !t.disabled))
);
}, [searchQuery, originCaip2Id, destinationCaip2Id, tokenRoutes]);
}, [searchQuery, origin, destination]);
return (
<div className="flex flex-col items-stretch">
{tokens.length ? (
tokens.map((t) => (
tokens.map((t, i) => (
<button
className={`-mx-2 py-2 px-2 rounded mb-2 flex items-center ${
t.disabled ? 'opacity-50' : 'hover:bg-gray-200'
} transition-all duration-250`}
key={t.tokenCaip19Id}
key={i}
type="button"
disabled={t.disabled}
onClick={() => onSelect(t)}
onClick={() => onSelect(t.token)}
>
<div className="shrink-0">
<TokenIcon token={t} size={30} />
<TokenIcon token={t.token} size={30} />
</div>
<div className="ml-2 text-left shrink-0">
<div className="text-sm w-14 truncate">{t.symbol || 'Unknown'}</div>
<div className="text-xs text-gray-500 w-14 truncate">{t.name || 'Unknown'}</div>
<div className="text-sm w-14 truncate">{t.token.symbol || 'Unknown'}</div>
<div className="text-xs text-gray-500 w-14 truncate">{t.token.name || 'Unknown'}</div>
</div>
<div className="ml-2 text-left shrink min-w-0">
<div className="text-xs w-full truncate">
{isNativeToken(t.tokenCaip19Id)
? 'Native chain token'
: getTokenAddress(t.tokenCaip19Id)}
{t.token.addressOrDenom || 'Native chain token'}
</div>
<div className=" mt-0.5 text-xs flex space-x-1">
<span>{`Decimals: ${t.decimals}`}</span>
<span>{`Decimals: ${t.token.decimals}`}</span>
<span>-</span>
<span>{`Type: ${getAssetNamespace(t.tokenCaip19Id)}`}</span>
<span>{`Chain: ${getChainDisplayName(t.token.chainName)}`}</span>
</div>
</div>
{t.disabled && (
@ -153,8 +139,8 @@ export function TokenList({
className="ml-auto mr-1"
data-te-toggle="tooltip"
title={`Route not supported for ${getChainDisplayName(
originCaip2Id,
)} to ${getChainDisplayName(destinationCaip2Id)}`}
origin,
)} to ${getChainDisplayName(destination)}`}
/>
)}
</button>

@ -1,69 +1,56 @@
import { useFormikContext } from 'formik';
import { useField, useFormikContext } from 'formik';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { IToken } from '@hyperlane-xyz/sdk';
import { TokenIcon } from '../../components/icons/TokenIcon';
import { getIndexForToken, getTokenByIndex, getWarpCore } from '../../context/context';
import ChevronIcon from '../../images/icons/chevron-down.svg';
import { isNonFungibleToken } from '../caip/tokens';
import { RoutesMap } from '../routes/types';
import { getTokenRoutes } from '../routes/utils';
import { TransferFormValues } from '../transfer/types';
import { TokenListModal } from './TokenListModal';
import { getToken } from './metadata';
import { TokenMetadata } from './types';
type Props = {
name: string;
originCaip2Id: ChainCaip2Id;
destinationCaip2Id: ChainCaip2Id;
tokenRoutes: RoutesMap;
disabled?: boolean;
setIsNft: (value: boolean) => void;
};
export function TokenSelectField({
name,
originCaip2Id,
destinationCaip2Id,
tokenRoutes,
disabled,
setIsNft,
}: Props) {
const { values, setFieldValue } = useFormikContext<TransferFormValues>();
// Keep local state for token details, but let formik manage field value
const [token, setToken] = useState<TokenMetadata | undefined>(undefined);
export function TokenSelectField({ name, disabled, setIsNft }: Props) {
const { values } = useFormikContext<TransferFormValues>();
const [field, , helpers] = useField<number | undefined>(name);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAutomaticSelection, setIsAutomaticSelection] = useState(false);
// Keep local state in sync with formik state
const { origin, destination } = values;
useEffect(() => {
const routes = getTokenRoutes(originCaip2Id, destinationCaip2Id, tokenRoutes);
let newFieldValue: TokenCaip19Id | undefined = undefined;
let newToken: TokenMetadata | undefined = undefined;
let newIsAutomatic = true;
if (routes.length === 1) {
newFieldValue = routes[0].baseTokenCaip19Id;
newToken = getToken(newFieldValue);
} else if (routes.length > 1) {
newFieldValue = values[name] || routes[0].baseTokenCaip19Id;
newToken = getToken(newFieldValue!);
const tokensWithRoute = getWarpCore().getTokensForRoute(origin, destination);
let newFieldValue: number | undefined;
let newIsAutomatic: boolean;
// No tokens available for this route
if (tokensWithRoute.length === 0) {
newFieldValue = undefined;
newIsAutomatic = true;
}
// Exactly one found
else if (tokensWithRoute.length === 1) {
newFieldValue = getIndexForToken(tokensWithRoute[0]);
newIsAutomatic = true;
// Multiple possibilities
} else {
newFieldValue = undefined;
newIsAutomatic = false;
}
setToken(newToken);
setFieldValue(name, newFieldValue || '');
helpers.setValue(newFieldValue);
setIsAutomaticSelection(newIsAutomatic);
}, [name, token, values, originCaip2Id, destinationCaip2Id, tokenRoutes, setFieldValue]);
}, [origin, destination, helpers]);
const onSelectToken = (newToken: TokenMetadata) => {
const onSelectToken = (newToken: IToken) => {
// Set the token address value in formik state
setFieldValue(name, newToken.tokenCaip19Id);
// reset amount after change token
setFieldValue('amount', '');
// Update local state
setToken(newToken);
helpers.setValue(getIndexForToken(newToken));
// Update nft state in parent
setIsNft(!!isNonFungibleToken(newToken.tokenCaip19Id));
setIsNft(newToken.isNft());
};
const onClickField = () => {
@ -73,8 +60,7 @@ export function TokenSelectField({
return (
<>
<TokenButton
token={token}
name={name}
token={getTokenByIndex(field.value)}
disabled={isAutomaticSelection || disabled}
onClick={onClickField}
isAutomatic={isAutomaticSelection}
@ -83,9 +69,8 @@ export function TokenSelectField({
isOpen={isModalOpen}
close={() => setIsModalOpen(false)}
onSelect={onSelectToken}
originCaip2Id={originCaip2Id}
destinationCaip2Id={destinationCaip2Id}
tokenRoutes={tokenRoutes}
origin={values.origin}
destination={values.destination}
/>
</>
);
@ -93,13 +78,11 @@ export function TokenSelectField({
function TokenButton({
token,
name,
disabled,
onClick,
isAutomatic,
}: {
token?: TokenMetadata;
name: string;
token?: IToken;
disabled?: boolean;
onClick?: () => void;
isAutomatic?: boolean;
@ -107,7 +90,6 @@ function TokenButton({
return (
<button
type="button"
name={name}
className={`${styles.base} ${disabled ? styles.disabled : styles.enabled}`}
onClick={onClick}
>

@ -1,31 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { ProtocolType, eqAddress } from '@hyperlane-xyz/utils';
import { IToken } from '@hyperlane-xyz/sdk';
import { useToastError } from '../../components/toast/useToastError';
import { logger } from '../../utils/logger';
import { getProtocolType } from '../caip/chains';
import { getTokenAddress, isNativeToken, isNonFungibleToken } from '../caip/tokens';
import { getEvmProvider } from '../multiProvider';
import { Route } from '../routes/types';
import { isRouteFromCollateral, isWarpRoute } from '../routes/utils';
import { getWarpCore } from '../../context/context';
import { useAccountAddressForChain } from '../wallet/hooks/multiProtocol';
import { getErc20Contract, getErc721Contract } from './contracts/evmContracts';
export function useIsApproveRequired(
tokenCaip19Id: TokenCaip19Id,
amount: string,
route?: Route,
enabled = true,
) {
const owner = useAccountAddressForChain(route?.originCaip2Id);
export function useIsApproveRequired(token?: IToken, amount?: string, enabled = true) {
const owner = useAccountAddressForChain(token?.chainName);
const { isLoading, isError, error, data } = useQuery({
queryKey: ['useIsApproveRequired', route, tokenCaip19Id, owner, amount],
queryKey: ['useIsApproveRequired', owner, amount, token?.addressOrDenom],
queryFn: async () => {
if (!route || !tokenCaip19Id || !owner || !amount) return false;
return isApproveRequired(route, tokenCaip19Id, amount, owner);
if (!token || !owner || !amount) return false;
return getWarpCore().isApproveRequired({ originTokenAmount: token.amount(amount), owner });
},
enabled,
});
@ -34,34 +22,3 @@ export function useIsApproveRequired(
return { isLoading, isError, isApproveRequired: !!data };
}
export async function isApproveRequired(
route: Route,
tokenCaip19Id: TokenCaip19Id,
amount: string,
owner: Address,
) {
if (
isNativeToken(tokenCaip19Id) ||
!isRouteFromCollateral(route) ||
!isWarpRoute(route) ||
getProtocolType(route.originCaip2Id) !== ProtocolType.Ethereum
) {
return false;
}
const spender = route.baseRouterAddress;
const provider = getEvmProvider(route.originCaip2Id);
const tokenAddress = getTokenAddress(tokenCaip19Id);
let isRequired: boolean;
if (isNonFungibleToken(tokenCaip19Id)) {
const contract = getErc721Contract(tokenAddress, provider);
const approvedAddress = await contract.getApproved(amount);
isRequired = !eqAddress(approvedAddress, spender);
} else {
const contract = getErc20Contract(tokenAddress, provider);
const allowance = await contract.allowance(owner, spender);
isRequired = allowance.lt(amount);
}
logger.debug(`Approval is${isRequired ? '' : ' not'} required for transfer of ${tokenCaip19Id}`);
return isRequired;
}

@ -1,232 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { eqAddress, isValidAddress } from '@hyperlane-xyz/utils';
import { IToken } from '@hyperlane-xyz/sdk';
import { isValidAddress } from '@hyperlane-xyz/utils';
import { useToastError } from '../../components/toast/useToastError';
import { logger } from '../../utils/logger';
import { getProtocolType } from '../caip/chains';
import { parseCaip19Id, tryGetChainIdFromToken } from '../caip/tokens';
import { getEvmProvider } from '../multiProvider';
import { RoutesMap } from '../routes/types';
import { getTokenRoute, isIbcOnlyRoute, isIbcRoute, isRouteFromNative } from '../routes/utils';
import { useStore } from '../store';
import { getMultiProvider, getTokenByIndex } from '../../context/context';
import { TransferFormValues } from '../transfer/types';
import { useAccountAddressForChain } from '../wallet/hooks/multiProtocol';
import { AdapterFactory } from './AdapterFactory';
import { getHypErc721Contract } from './contracts/evmContracts';
export function useOriginBalance(
{ originCaip2Id, destinationCaip2Id, tokenCaip19Id }: TransferFormValues,
tokenRoutes: RoutesMap,
) {
const address = useAccountAddressForChain(originCaip2Id);
const setSenderBalances = useStore((state) => state.setSenderBalances);
export function useBalance(chain?: ChainName, token?: IToken, address?: Address) {
const { isLoading, isError, error, data } = useQuery({
queryKey: [
'useOriginBalance',
address,
originCaip2Id,
destinationCaip2Id,
tokenCaip19Id,
tokenRoutes,
],
queryFn: async () => {
const route = getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes);
const protocol = getProtocolType(originCaip2Id);
if (!route || !address || !isValidAddress(address, protocol)) return null;
const tokenAdapter = isIbcRoute(route)
? AdapterFactory.NativeAdapterFromRoute(route, 'origin')
: AdapterFactory.HypTokenAdapterFromRouteOrigin(route);
const tokenBalance = await tokenAdapter.getBalance(address);
let nativeBalance;
if (isRouteFromNative(route) || isIbcRoute(route)) {
nativeBalance = tokenBalance;
} else {
const nativeAdapter = AdapterFactory.NativeAdapterFromChain(originCaip2Id);
nativeBalance = await nativeAdapter.getBalance(address);
}
return { tokenBalance, tokenDecimals: route.originDecimals, nativeBalance };
queryKey: ['useBalance', chain, address, token?.addressOrDenom],
queryFn: () => {
if (!chain || !token || !address || !isValidAddress(address, token.protocol)) return null;
return token.getBalance(getMultiProvider(), address);
},
refetchInterval: 5000,
});
useToastError(error, 'Error fetching origin balance');
useEffect(() => {
setSenderBalances(data?.tokenBalance || '0', data?.nativeBalance || '0');
}, [data, setSenderBalances]);
useToastError(error, 'Error fetching balance');
return {
isLoading,
isError,
tokenBalance: data?.tokenBalance,
tokenDecimals: data?.tokenDecimals,
nativeBalance: data?.nativeBalance,
balance: data ?? undefined,
};
}
export function useDestinationBalance(
{ originCaip2Id, destinationCaip2Id, tokenCaip19Id, recipientAddress }: TransferFormValues,
tokenRoutes: RoutesMap,
) {
const { isLoading, isError, error, data } = useQuery({
queryKey: [
'useDestinationBalance',
recipientAddress,
originCaip2Id,
destinationCaip2Id,
tokenCaip19Id,
tokenRoutes,
],
queryFn: async () => {
const route = getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes);
const protocol = getProtocolType(destinationCaip2Id);
if (!route || !recipientAddress || !isValidAddress(recipientAddress, protocol)) return null;
const tokenAdapter = isIbcOnlyRoute(route)
? AdapterFactory.NativeAdapterFromRoute(route, 'destination')
: AdapterFactory.HypTokenAdapterFromRouteDest(route);
const balance = await tokenAdapter.getBalance(recipientAddress);
return { balance, decimals: route.destDecimals };
},
refetchInterval: 5000,
});
useToastError(error, 'Error fetching destination balance');
return { isLoading, isError, balance: data?.balance, decimals: data?.decimals };
}
// TODO solana support
export function useOriginTokenIdBalance(tokenCaip19Id: TokenCaip19Id) {
const chainCaip2Id = tryGetChainIdFromToken(tokenCaip19Id);
const accountAddress = useAccountAddressForChain(chainCaip2Id);
const setSenderNftIds = useStore((state) => state.setSenderNftIds);
const {
isLoading,
isError,
error,
data: tokenIds,
} = useQuery({
queryKey: ['useOriginTokenIdBalance', tokenCaip19Id, accountAddress],
queryFn: () => {
if (!tokenCaip19Id || !accountAddress) return null;
return fetchListOfERC721TokenId(tokenCaip19Id, accountAddress);
},
refetchInterval: 5000,
});
useToastError(error, 'Error fetching origin token IDs');
useEffect(() => {
setSenderNftIds(tokenIds && Array.isArray(tokenIds) ? tokenIds : null);
}, [tokenIds, setSenderNftIds]);
return { isLoading, isError, tokenIds };
}
// TODO solana support
export async function fetchListOfERC721TokenId(
tokenCaip19Id: TokenCaip19Id,
accountAddress: Address,
): Promise<string[]> {
const { chainCaip2Id, address: tokenAddress } = parseCaip19Id(tokenCaip19Id);
logger.debug(`Fetching list of tokenID for account ${accountAddress} on chain ${chainCaip2Id}`);
const hypERC721 = getHypErc721Contract(tokenAddress, getEvmProvider(chainCaip2Id));
const balance = await hypERC721.balanceOf(accountAddress);
const index = Array.from({ length: parseInt(balance.toString()) }, (_, index) => index);
const promises: Promise<string>[] = index.map(async (id) => {
const response = await hypERC721.tokenOfOwnerByIndex(accountAddress, id);
return response.toString();
});
const result = await Promise.all(promises);
logger.debug(`TokenIds that the ${accountAddress} owns on chain ${chainCaip2Id}: ${result} `);
return result;
}
// TODO solana support
export function useContractSupportsTokenByOwner(
tokenCaip19Id: TokenCaip19Id,
accountAddress?: Address,
) {
const {
isLoading,
isError,
error,
data: isContractAllowToGetTokenIds,
} = useQuery({
queryKey: ['useContractSupportsTokenByOwner', tokenCaip19Id, accountAddress],
queryFn: () => {
if (!tokenCaip19Id || !accountAddress) return null;
return contractSupportsTokenByOwner(tokenCaip19Id, accountAddress);
},
});
useToastError(error, 'Error ERC721 contract details');
return { isLoading, isError, isContractAllowToGetTokenIds };
}
// TODO solana support
async function contractSupportsTokenByOwner(
tokenCaip19Id: TokenCaip19Id,
accountAddress: Address,
): Promise<boolean> {
const { chainCaip2Id, address: tokenAddress } = parseCaip19Id(tokenCaip19Id);
const hypERC721 = getHypErc721Contract(tokenAddress, getEvmProvider(chainCaip2Id));
try {
await hypERC721.tokenOfOwnerByIndex(accountAddress, '0');
return true;
} catch (error) {
return false;
}
}
// TODO solana support
export function useIsSenderNftOwner(tokenCaip19Id: TokenCaip19Id, tokenId: string) {
const chainCaip2Id = tryGetChainIdFromToken(tokenCaip19Id);
const senderAddress = useAccountAddressForChain(chainCaip2Id);
const setIsSenderNftOwner = useStore((state) => state.setIsSenderNftOwner);
const {
isLoading,
isError,
error,
data: owner,
} = useQuery({
queryKey: ['useOwnerOfErc721', tokenCaip19Id, tokenId],
queryFn: () => {
if (!tokenCaip19Id || !tokenId) return null;
return fetchERC721Owner(tokenCaip19Id, tokenId);
},
});
useToastError(error, 'Error ERC721 owner');
useEffect(() => {
if (!senderAddress || !owner) setIsSenderNftOwner(null);
else setIsSenderNftOwner(eqAddress(senderAddress, owner));
}, [owner, senderAddress, setIsSenderNftOwner]);
return { isLoading, isError, owner };
export function useOriginBalance({ origin, tokenIndex }: TransferFormValues) {
const address = useAccountAddressForChain(origin);
const token = getTokenByIndex(tokenIndex);
return useBalance(origin, token, address);
}
// TODO solana support
async function fetchERC721Owner(tokenCaip19Id: TokenCaip19Id, tokenId: string): Promise<string> {
const { chainCaip2Id, address: tokenAddress } = parseCaip19Id(tokenCaip19Id);
const hypERC721 = getHypErc721Contract(tokenAddress, getEvmProvider(chainCaip2Id));
try {
const ownerAddress = await hypERC721.ownerOf(tokenId);
return ownerAddress;
} catch (error) {
return '';
}
export function useDestinationBalance({
origin,
destination,
tokenIndex,
recipient,
}: TransferFormValues) {
const originToken = getTokenByIndex(tokenIndex);
const connection = originToken?.getConnectionForChain(destination);
return useBalance(origin, connection?.token, recipient);
}

@ -1,60 +0,0 @@
import { Signer, providers } from 'ethers';
import {
ERC20__factory,
ERC721__factory,
HypERC20Collateral__factory,
HypERC20__factory,
HypERC721Collateral__factory,
HypERC721__factory,
HypNative__factory,
} from '@hyperlane-xyz/core';
export function getHypErc20CollateralContract(
contractAddress: Address,
signerOrProvider: Signer | providers.Provider,
) {
return HypERC20Collateral__factory.connect(contractAddress, signerOrProvider);
}
export function getHypErc721CollateralContract(
contractAddress: Address,
signerOrProvider: Signer | providers.Provider,
) {
return HypERC721Collateral__factory.connect(contractAddress, signerOrProvider);
}
export function getHypErc20Contract(
contractAddress: Address,
signerOrProvider: Signer | providers.Provider,
) {
return HypERC20__factory.connect(contractAddress, signerOrProvider);
}
export function getHypErc721Contract(
contractAddress: Address,
signerOrProvider: Signer | providers.Provider,
) {
return HypERC721__factory.connect(contractAddress, signerOrProvider);
}
export function getHypNativeContract(
contractAddress: Address,
signerOrProvider: Signer | providers.Provider,
) {
return HypNative__factory.connect(contractAddress, signerOrProvider);
}
export function getErc20Contract(
contractAddress: Address,
signerOrProvider: Signer | providers.Provider,
) {
return ERC20__factory.connect(contractAddress, signerOrProvider);
}
export function getErc721Contract(
contractAddress: Address,
signerOrProvider: Signer | providers.Provider,
) {
return ERC721__factory.connect(contractAddress, signerOrProvider);
}

@ -1,19 +0,0 @@
import { getWarpContext } from '../../context/context';
import { IbcTokenTypes, TokenMetadata } from './types';
export function getTokens() {
return getWarpContext()?.tokens || [];
}
export function getToken(tokenCaip19Id: TokenCaip19Id) {
return getTokens().find((t) => t.tokenCaip19Id === tokenCaip19Id);
}
export function findTokensByAddress(address: Address) {
return getTokens().filter((t) => t.tokenCaip19Id.includes(address));
}
export function isIbcToken(token: TokenMetadata) {
return Object.values(IbcTokenTypes).includes(token.type as IbcTokenTypes);
}

@ -1,125 +0,0 @@
import { z } from 'zod';
import { ERC20Metadata, TokenType } from '@hyperlane-xyz/sdk';
import { ProtocolType } from '@hyperlane-xyz/utils';
export type MinimalTokenMetadata = Omit<ERC20Metadata, 'totalSupply'>;
// Extend SDK's TokenType enum to allow for IBC token routes
export enum IbcTokenTypes {
IbcNative = 'ibc-native',
}
type ExtendedTokenType = TokenType | IbcTokenTypes;
// Define common fields for all token types
const commonTokenFields = z.object({
chainId: z.union([z.number().positive(), z.string()]),
name: z.string().optional(),
symbol: z.string().optional(),
decimals: z.number().nonnegative().optional(), // decimals == 0 for NFTs
logoURI: z.string().optional(),
igpTokenAddressOrDenom: z.string().optional(),
});
type CommonTokenFields = z.infer<typeof commonTokenFields>;
/**
* Types for the developer-provided config
* Seems redundant with the *Metadata types below but these are
* necessary to enable a more flexible and intuitive schema for the config
* E.g. allow literal strings for 'type' field
* or allow omitting 'address' for NativeTokenConfig
*
* See src/consts/tokens.ts
*/
type CommonFieldsWithLooseProtocol = Omit<CommonTokenFields, 'protocol'> & {
protocol?: `${ProtocolType}`;
};
interface BaseTokenConfig extends CommonFieldsWithLooseProtocol {
type: `${ExtendedTokenType}`; // use template literal to allow string values
}
const CollateralTokenSchema = commonTokenFields.extend({
type: z.literal(TokenType.collateral),
address: z.string(),
hypCollateralAddress: z.string(),
isNft: z.boolean().optional(),
isSpl2022: z.boolean().optional(), // Only required if using a 2022 version SPL Token on a Sealevel chain
});
interface CollateralTokenConfig extends BaseTokenConfig {
// Typescript does not allow literal value value 'collateral' even if it matches the enum's value
type: TokenType.collateral | 'collateral';
address: Address;
hypCollateralAddress: Address;
isNft?: boolean;
isSpl2022?: boolean;
}
const NativeTokenSchema = commonTokenFields.extend({
type: z.literal(TokenType.native),
hypNativeAddress: z.string(),
});
interface NativeTokenConfig extends BaseTokenConfig {
type: TokenType.native | 'native';
hypNativeAddress: Address;
}
const IbcNativeTokenSchema = commonTokenFields.extend({
type: z.literal(IbcTokenTypes.IbcNative),
});
interface IbcNativeTokenConfig extends BaseTokenConfig {
type: IbcTokenTypes.IbcNative | 'ibc-native';
}
export const WarpTokenConfigSchema = z.array(
CollateralTokenSchema.or(NativeTokenSchema).or(IbcNativeTokenSchema),
);
export type WarpTokenConfig = Array<
CollateralTokenConfig | NativeTokenConfig | IbcNativeTokenConfig
>;
/**
* Types for use in the app after processing config
* Uses unambiguous CAIP IDs
*
* See src/features/tokens/metadata.ts
*/
interface BaseTokenMetadata extends MinimalTokenMetadata {
type: ExtendedTokenType;
tokenCaip19Id: TokenCaip19Id;
routerAddress: Address; // Shared name for hypCollateralAddr or hypNativeAddr
igpTokenAddressOrDenom?: Address;
logoURI?: string;
}
interface CollateralTokenMetadata extends BaseTokenMetadata {
type: TokenType.collateral;
}
interface NativeTokenMetadata extends BaseTokenMetadata {
type: TokenType.native;
}
interface IbcNativeTokenMetadata extends BaseTokenMetadata {
type: IbcTokenTypes.IbcNative;
}
export type TokenMetadata = CollateralTokenMetadata | NativeTokenMetadata | IbcNativeTokenMetadata;
/**
* Extended types including synthetic hyp token addresses
*/
interface HypTokens {
hypTokens: Array<{ chain: ChainCaip2Id; router: Address; decimals: number }>;
}
type NativeTokenMetadataWithHypTokens = NativeTokenMetadata & HypTokens;
type CollateralTokenMetadataWithHypTokens = CollateralTokenMetadata & HypTokens;
type IbcNativeTokenMetadataWithHypTokens = IbcNativeTokenMetadata & HypTokens;
export type TokenMetadataWithHypTokens =
| CollateralTokenMetadataWithHypTokens
| NativeTokenMetadataWithHypTokens
| IbcNativeTokenMetadataWithHypTokens;

@ -1,7 +1,6 @@
import { WideChevron } from '@hyperlane-xyz/widgets';
import { Card } from '../../components/layout/Card';
import { getWarpContext } from '../../context/context';
import { Color } from '../../styles/Color';
import { TransferTokenForm } from './TransferTokenForm';
@ -19,7 +18,7 @@ export function TransferTokenCard() {
color={Color.primaryBlue}
/>
</div>
<TransferTokenForm tokenRoutes={getWarpContext().routes} />
<TransferTokenForm />
</>
</Card>
);

@ -1,9 +1,10 @@
import BigNumber from 'bignumber.js';
import { Form, Formik, useFormikContext } from 'formik';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { fromWei, fromWeiRounded, toWei } from '@hyperlane-xyz/utils';
import { TokenAmount } from '@hyperlane-xyz/sdk';
import { ProtocolType, errorToString, isNullish, toWei } from '@hyperlane-xyz/utils';
import { SmallSpinner } from '../../components/animation/SmallSpinner';
import { ConnectAwareSubmitButton } from '../../components/buttons/ConnectAwareSubmitButton';
@ -12,31 +13,32 @@ import { SolidButton } from '../../components/buttons/SolidButton';
import { ChevronIcon } from '../../components/icons/Chevron';
import { WideChevron } from '../../components/icons/WideChevron';
import { TextField } from '../../components/input/TextField';
import { getIndexForToken, getTokenByIndex, getTokens, getWarpCore } from '../../context/context';
import SwapIcon from '../../images/icons/swap.svg';
import { Color } from '../../styles/Color';
import { logger } from '../../utils/logger';
import { getTokenAddress, isNonFungibleToken } from '../caip/tokens';
import { ChainSelectField } from '../chains/ChainSelectField';
import { getChainDisplayName } from '../chains/utils';
import { useRouteChains } from '../routes/hooks';
import { RoutesMap, WarpRoute } from '../routes/types';
import { getTokenRoute, isIbcOnlyRoute } from '../routes/utils';
import { useStore } from '../store';
import { SelectOrInputTokenIds } from '../tokens/SelectOrInputTokenIds';
import { TokenSelectField } from '../tokens/TokenSelectField';
import { useIsApproveRequired } from '../tokens/approval';
import { useDestinationBalance, useOriginBalance } from '../tokens/balances';
import { getToken } from '../tokens/metadata';
import { useAccountAddressForChain, useAccounts } from '../wallet/hooks/multiProtocol';
import {
getAccountAddressAndPubKey,
useAccountAddressForChain,
useAccounts,
} from '../wallet/hooks/multiProtocol';
import { AccountInfo } from '../wallet/hooks/types';
import { useFetchMaxAmount } from './maxAmount';
import { TransferFormValues } from './types';
import { useIgpQuote } from './useIgpQuote';
import { useRecipientBalanceWatcher } from './useBalanceWatcher';
import { useFeeQuotes } from './useFeeQuotes';
import { useTokenTransfer } from './useTokenTransfer';
import { validateFormValues } from './validateForm';
export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
const chainCaip2Ids = useRouteChains(tokenRoutes);
const initialValues = useFormInitialValues(chainCaip2Ids, tokenRoutes);
export function TransferTokenForm() {
const initialValues = useFormInitialValues();
const { accounts } = useAccounts();
// Flag for if form is in input vs review mode
@ -44,16 +46,10 @@ export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
// Flag for check current type of token
const [isNft, setIsNft] = useState(false);
const { balances, igpQuote } = useStore((state) => ({
balances: state.balances,
igpQuote: state.igpQuote,
}));
const validate = (values: TransferFormValues) =>
validateFormValues(values, tokenRoutes, balances, igpQuote, accounts);
const validate = (values: TransferFormValues) => validateForm(values, accounts);
const onSubmitForm = (values: TransferFormValues) => {
logger.debug('Reviewing transfer form values:', JSON.stringify(values));
logger.debug('Reviewing transfer form values for:', values.origin, values.destination);
setIsReview(true);
};
@ -65,32 +61,37 @@ export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
validateOnChange={false}
validateOnBlur={false}
>
<Form className="flex flex-col items-stretch w-full mt-2">
<ChainSelectSection chainCaip2Ids={chainCaip2Ids} isReview={isReview} />
<div className="mt-3 flex justify-between items-end space-x-4">
<TokenSection tokenRoutes={tokenRoutes} setIsNft={setIsNft} isReview={isReview} />
<AmountSection tokenRoutes={tokenRoutes} isNft={isNft} isReview={isReview} />
</div>
<RecipientSection tokenRoutes={tokenRoutes} isReview={isReview} />
<ReviewDetails visible={isReview} tokenRoutes={tokenRoutes} />
<ButtonSection tokenRoutes={tokenRoutes} isReview={isReview} setIsReview={setIsReview} />
</Form>
{({ isValidating }) => (
<Form className="flex flex-col items-stretch w-full mt-2">
<ChainSelectSection isReview={isReview} />
<div className="mt-3 flex justify-between items-end space-x-4">
<TokenSection setIsNft={setIsNft} isReview={isReview} />
<AmountSection isNft={isNft} isReview={isReview} />
</div>
<RecipientSection isReview={isReview} />
<ReviewDetails visible={isReview} />
<ButtonSection
isReview={isReview}
isValidating={isValidating}
setIsReview={setIsReview}
/>
</Form>
)}
</Formik>
);
}
function SwapChainsButton({ disabled }: { disabled?: boolean }) {
const { values, setFieldValue } = useFormikContext<TransferFormValues>();
const { originCaip2Id, destinationCaip2Id } = values;
const { origin, destination } = values;
const onClick = () => {
if (disabled) return;
setFieldValue('originCaip2Id', destinationCaip2Id);
setFieldValue('destinationCaip2Id', originCaip2Id);
setFieldValue('origin', destination);
setFieldValue('destination', origin);
// Reset other fields on chain change
setFieldValue('recipientAddress', '');
setFieldValue('amount', '');
setFieldValue('tokenCaip19Id', '');
setFieldValue('tokenIndex', undefined);
setFieldValue('recipient', '');
};
return (
@ -106,21 +107,12 @@ function SwapChainsButton({ disabled }: { disabled?: boolean }) {
);
}
function ChainSelectSection({
chainCaip2Ids,
isReview,
}: {
chainCaip2Ids: ChainCaip2Id[];
isReview: boolean;
}) {
function ChainSelectSection({ isReview }: { isReview: boolean }) {
const chains = useMemo(() => getWarpCore().getTokenChains(), []);
return (
<div className="flex items-center justify-center space-x-7 sm:space-x-10">
<ChainSelectField
name="originCaip2Id"
label="From"
chainCaip2Ids={chainCaip2Ids}
disabled={isReview}
/>
<ChainSelectField name="origin" label="From" chains={chains} disabled={isReview} />
<div className="flex flex-col items-center">
<div className="flex mb-6 sm:space-x-1.5">
<WideChevron classes="hidden sm:block" />
@ -129,55 +121,31 @@ function ChainSelectSection({
</div>
<SwapChainsButton disabled={isReview} />
</div>
<ChainSelectField
name="destinationCaip2Id"
label="To"
chainCaip2Ids={chainCaip2Ids}
disabled={isReview}
/>
<ChainSelectField name="destination" label="To" chains={chains} disabled={isReview} />
</div>
);
}
function TokenSection({
tokenRoutes,
setIsNft,
isReview,
}: {
tokenRoutes: RoutesMap;
setIsNft: (b: boolean) => void;
isReview: boolean;
}) {
const { values } = useFormikContext<TransferFormValues>();
return (
<div className="flex-1">
<label htmlFor="tokenCaip19Id" className="block uppercase text-sm text-gray-500 pl-0.5">
<label htmlFor="tokenIndex" className="block uppercase text-sm text-gray-500 pl-0.5">
Token
</label>
<TokenSelectField
name="tokenCaip19Id"
originCaip2Id={values.originCaip2Id}
destinationCaip2Id={values.destinationCaip2Id}
tokenRoutes={tokenRoutes}
disabled={isReview}
setIsNft={setIsNft}
/>
<TokenSelectField name="tokenIndex" disabled={isReview} setIsNft={setIsNft} />
</div>
);
}
function AmountSection({
tokenRoutes,
isNft,
isReview,
}: {
tokenRoutes: RoutesMap;
isNft: boolean;
isReview: boolean;
}) {
function AmountSection({ isNft, isReview }: { isNft: boolean; isReview: boolean }) {
const { values } = useFormikContext<TransferFormValues>();
const { tokenBalance, tokenDecimals } = useOriginBalance(values, tokenRoutes);
const { balance } = useOriginBalance(values);
return (
<div className="flex-1">
@ -185,10 +153,10 @@ function AmountSection({
<label htmlFor="amount" className="block uppercase text-sm text-gray-500 pl-0.5">
Amount
</label>
<TokenBalance label="My balance" balance={tokenBalance} decimals={tokenDecimals} />
<TokenBalance label="My balance" balance={balance} />
</div>
{isNft ? (
<SelectOrInputTokenIds disabled={isReview} tokenRoutes={tokenRoutes} />
<SelectOrInputTokenIds disabled={isReview} />
) : (
<div className="relative w-full">
<TextField
@ -199,56 +167,29 @@ function AmountSection({
step="any"
disabled={isReview}
/>
<MaxButton disabled={isReview} balance={tokenBalance} decimals={tokenDecimals} />
<MaxButton disabled={isReview} balance={balance} />
</div>
)}
</div>
);
}
function RecipientSection({
tokenRoutes,
isReview,
}: {
tokenRoutes: RoutesMap;
isReview: boolean;
}) {
function RecipientSection({ isReview }: { isReview: boolean }) {
const { values } = useFormikContext<TransferFormValues>();
const { balance, decimals } = useDestinationBalance(values, tokenRoutes);
// A crude way to detect transfer completions by triggering
// toast on recipientAddress balance increase. This is not ideal because it
// could confuse unrelated balance changes for message delivery
// TODO replace with a polling worker that queries the hyperlane explorer
const recipientAddress = values.recipientAddress;
const prevRecipientBalance = useRef<{ balance?: string; recipientAddress?: string }>({
balance: '',
recipientAddress: '',
});
useEffect(() => {
if (
recipientAddress &&
balance &&
prevRecipientBalance.current.balance &&
prevRecipientBalance.current.recipientAddress === recipientAddress &&
new BigNumber(balance).gt(prevRecipientBalance.current.balance)
) {
toast.success('Recipient has received funds, transfer complete!');
}
prevRecipientBalance.current = { balance, recipientAddress };
}, [balance, recipientAddress, prevRecipientBalance]);
const { balance } = useDestinationBalance(values);
useRecipientBalanceWatcher(values.recipient, balance);
return (
<div className="mt-4">
<div className="flex justify-between pr-1">
<label htmlFor="recipientAddress" className="block uppercase text-sm text-gray-500 pl-0.5">
<label htmlFor="recipient" className="block uppercase text-sm text-gray-500 pl-0.5">
Recipient Address
</label>
<TokenBalance label="Remote balance" balance={balance} decimals={decimals} />
<TokenBalance label="Remote balance" balance={balance} />
</div>
<div className="relative w-full">
<TextField
name="recipientAddress"
name="recipient"
placeholder="0x123456..."
classes="w-full"
disabled={isReview}
@ -259,26 +200,18 @@ function RecipientSection({
);
}
function TokenBalance({
label,
balance,
decimals,
}: {
label: string;
balance?: string | null;
decimals?: number;
}) {
const value = !decimals ? fromWei(balance, decimals) : fromWeiRounded(balance, decimals);
function TokenBalance({ label, balance }: { label: string; balance?: TokenAmount | null }) {
const value = balance?.getDecimalFormattedAmount().toFixed(4) || '0';
return <div className="text-xs text-gray-500 text-right">{`${label}: ${value}`}</div>;
}
function ButtonSection({
tokenRoutes,
isReview,
isValidating,
setIsReview,
}: {
tokenRoutes: RoutesMap;
isReview: boolean;
isValidating: boolean;
setIsReview: (b: boolean) => void;
}) {
const { values } = useFormikContext<TransferFormValues>();
@ -296,14 +229,14 @@ function ButtonSection({
const triggerTransactionsHandler = async () => {
setTransferLoading(true);
await triggerTransactions(values, tokenRoutes);
await triggerTransactions(values);
};
if (!isReview) {
return (
<ConnectAwareSubmitButton
chainCaip2Id={values.originCaip2Id}
text="Continue"
chainName={values.origin}
text={isValidating ? 'Validating...' : 'Continue'}
classes="mt-4 px-3 py-1.5"
/>
);
@ -326,25 +259,26 @@ function ButtonSection({
onClick={triggerTransactionsHandler}
classes="flex-1 px-3 py-1.5"
>
{`Send to ${getChainDisplayName(values.destinationCaip2Id)}`}
{`Send to ${getChainDisplayName(values.destination)}`}
</SolidButton>
</div>
);
}
function MaxButton({
balance,
decimals,
disabled,
}: {
balance?: string | null;
decimals?: number;
disabled?: boolean;
}) {
const { setFieldValue } = useFormikContext<TransferFormValues>();
const onClick = () => {
if (balance && !disabled) setFieldValue('amount', fromWeiRounded(balance, decimals));
function MaxButton({ balance, disabled }: { balance?: TokenAmount; disabled?: boolean }) {
const { values, setFieldValue } = useFormikContext<TransferFormValues>();
const { origin, destination, tokenIndex } = values;
const { accounts } = useAccounts();
const { fetchMaxAmount, isLoading } = useFetchMaxAmount();
const onClick = async () => {
if (!balance || isNullish(tokenIndex) || disabled) return;
const maxAmount = await fetchMaxAmount({ balance, origin, destination, accounts });
const decimalsAmount = maxAmount.getDecimalFormattedAmount();
const roundedAmount = new BigNumber(decimalsAmount).toFixed(4, BigNumber.ROUND_FLOOR);
setFieldValue('amount', roundedAmount);
};
return (
<SolidButton
type="button"
@ -353,21 +287,27 @@ function MaxButton({
disabled={disabled}
classes="text-xs absolute right-0.5 top-2 bottom-0.5 px-2"
>
MAX
{isLoading ? (
<div className="flex items-center">
<SmallSpinner />
</div>
) : (
'MAX'
)}
</SolidButton>
);
}
function SelfButton({ disabled }: { disabled?: boolean }) {
const { values, setFieldValue } = useFormikContext<TransferFormValues>();
const address = useAccountAddressForChain(values.destinationCaip2Id);
const address = useAccountAddressForChain(values.destination);
const onClick = () => {
if (disabled) return;
if (address) setFieldValue('recipientAddress', address);
if (address) setFieldValue('recipient', address);
else
toast.warn(
`No account found for for chain ${getChainDisplayName(
values.destinationCaip2Id,
values.destination,
)}, is your wallet connected?`,
);
};
@ -384,31 +324,23 @@ function SelfButton({ disabled }: { disabled?: boolean }) {
);
}
function ReviewDetails({ visible, tokenRoutes }: { visible: boolean; tokenRoutes: RoutesMap }) {
const {
values: { amount, originCaip2Id, destinationCaip2Id, tokenCaip19Id },
} = useFormikContext<TransferFormValues>();
// TODO cosmos: Need better handling of IBC route type (remove cast)
const route = getTokenRoute(
originCaip2Id,
destinationCaip2Id,
tokenCaip19Id,
tokenRoutes,
) as WarpRoute;
const isNft = tokenCaip19Id && isNonFungibleToken(tokenCaip19Id);
const amountWei = isNft ? amount.toString() : toWei(amount, route?.originDecimals);
const originToken = getToken(tokenCaip19Id);
function ReviewDetails({ visible }: { visible: boolean }) {
const { values } = useFormikContext<TransferFormValues>();
const { amount, destination, tokenIndex } = values;
const originToken = getTokenByIndex(tokenIndex);
const originTokenSymbol = originToken?.symbol || '';
const connection = originToken?.getConnectionForChain(destination);
const destinationToken = connection?.token;
const isNft = originToken?.isNft();
const amountWei = isNft ? amount.toString() : toWei(amount, originToken?.decimals);
const { isLoading: isApproveLoading, isApproveRequired } = useIsApproveRequired(
tokenCaip19Id,
originToken,
amountWei,
route,
visible,
);
const { isLoading: isQuoteLoading, igpQuote } = useIgpQuote(route);
const showIgpQuote = route && !isIbcOnlyRoute(route);
const { isLoading: isQuoteLoading, fees } = useFeeQuotes(values, visible);
const isLoading = isApproveLoading || isQuoteLoading;
@ -419,73 +351,96 @@ function ReviewDetails({ visible, tokenRoutes }: { visible: boolean; tokenRoutes
} overflow-hidden transition-all`}
>
<label className="mt-4 block uppercase text-sm text-gray-500 pl-0.5">Transactions</label>
{isLoading ? (
<div className="py-6 flex items-center justify-center">
<SmallSpinner />
</div>
) : (
<div className="mt-1.5 px-2.5 py-2 space-y-2 rounded border border-gray-400 bg-gray-150 text-sm break-all">
{isApproveRequired && (
<div className="mt-1.5 px-2.5 py-2 space-y-2 rounded border border-gray-400 bg-gray-150 text-sm break-all">
{isLoading ? (
<div className="py-6 flex items-center justify-center">
<SmallSpinner />
</div>
) : (
<>
{isApproveRequired && (
<div>
<h4>Transaction 1: Approve Transfer</h4>
<div className="mt-1.5 ml-1.5 pl-2 border-l border-gray-300 space-y-1.5 text-xs">
<p>{`Router Address: ${originToken?.addressOrDenom}`}</p>
{originToken?.collateralAddressOrDenom && (
<p>{`Collateral Address: ${originToken.collateralAddressOrDenom}`}</p>
)}
</div>
</div>
)}
<div>
<h4>Transaction 1: Approve Transfer</h4>
<h4>{`Transaction${isApproveRequired ? ' 2' : ''}: Transfer Remote`}</h4>
<div className="mt-1.5 ml-1.5 pl-2 border-l border-gray-300 space-y-1.5 text-xs">
<p>{`Token Address: ${getTokenAddress(tokenCaip19Id)}`}</p>
{route?.baseRouterAddress && (
<p>{`Collateral Address: ${route.baseRouterAddress}`}</p>
{destinationToken?.addressOrDenom && (
<p className="flex">
<span className="min-w-[7rem]">Remote Token</span>
<span>{destinationToken.addressOrDenom}</span>
</p>
)}
</div>
</div>
)}
<div>
<h4>{`Transaction${isApproveRequired ? ' 2' : ''}: Transfer Remote`}</h4>
<div className="mt-1.5 ml-1.5 pl-2 border-l border-gray-300 space-y-1.5 text-xs">
{route?.destRouterAddress && (
<p className="flex">
<span className="min-w-[7rem]">Remote Token</span>
<span>{route.destRouterAddress}</span>
</p>
)}
{isNft ? (
<p className="flex">
<span className="min-w-[7rem]">Token ID</span>
<span>{amount}</span>
<span className="min-w-[7rem]">{isNft ? 'Token ID' : 'Amount'}</span>
<span>{`${amount} ${originTokenSymbol}`}</span>
</p>
) : (
<>
{fees?.localQuote && fees.localQuote.amount > 0n && (
<p className="flex">
<span className="min-w-[7rem]">Amount</span>
<span>{`${amount} ${originTokenSymbol}`}</span>
<span className="min-w-[7rem]">Local Gas (est.)</span>
<span>{`${fees.localQuote.getDecimalFormattedAmount().toFixed(4) || '0'} ${
fees.localQuote.token.symbol || ''
}`}</span>
</p>
{showIgpQuote && (
<p className="flex">
<span className="min-w-[7rem]">Interchain Gas</span>
<span>{`${igpQuote?.amount || '0'} ${igpQuote?.token?.symbol || ''}`}</span>
</p>
)}
</>
)}
)}
{fees?.interchainQuote && fees.interchainQuote.amount > 0n && (
<p className="flex">
<span className="min-w-[7rem]">Interchain Gas</span>
<span>{`${fees.interchainQuote.getDecimalFormattedAmount().toFixed(4) || '0'} ${
fees.interchainQuote.token.symbol || ''
}`}</span>
</p>
)}
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
function useFormInitialValues(
chainCaip2Ids: ChainCaip2Id[],
tokenRoutes: RoutesMap,
): TransferFormValues {
function useFormInitialValues(): TransferFormValues {
return useMemo(() => {
const firstRoute = Object.values(tokenRoutes[chainCaip2Ids[0]]).filter(
(routes) => routes.length,
)[0][0];
const firstToken = getTokens()[0];
const connectedToken = firstToken.connections?.[0];
return {
originCaip2Id: firstRoute.originCaip2Id,
destinationCaip2Id: firstRoute.destCaip2Id,
origin: firstToken.chainName,
destination: connectedToken?.token?.chainName || '',
tokenIndex: getIndexForToken(firstToken),
amount: '',
tokenCaip19Id: firstRoute.baseTokenCaip19Id,
recipientAddress: '',
recipient: '',
};
}, [chainCaip2Ids, tokenRoutes]);
}, []);
}
async function validateForm(
values: TransferFormValues,
accounts: Record<ProtocolType, AccountInfo>,
) {
try {
const { origin, destination, tokenIndex, amount, recipient } = values;
const token = getTokenByIndex(tokenIndex);
if (!token) return { token: 'Token is required' };
const amountWei = toWei(amount, token.decimals);
const { address, publicKey: senderPubKey } = getAccountAddressAndPubKey(origin, accounts);
const result = await getWarpCore().validateTransfer({
originTokenAmount: token.amount(amountWei),
destination,
recipient,
sender: address || '',
senderPubKey: await senderPubKey,
});
return result;
} catch (error) {
logger.error('Error validating form', error);
return { form: errorToString(error) };
}
}

@ -1,7 +1,6 @@
import Image from 'next/image';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { isZeroishAddress, toTitleCase } from '@hyperlane-xyz/utils';
import { MessageStatus, MessageTimeline, useMessageTimeline } from '@hyperlane-xyz/widgets';
import { Spinner } from '../../components/animation/Spinner';
@ -10,6 +9,7 @@ import { ChainLogo } from '../../components/icons/ChainLogo';
import { TokenIcon } from '../../components/icons/TokenIcon';
import { WideChevron } from '../../components/icons/WideChevron';
import { Modal } from '../../components/layout/Modal';
import { getMultiProvider, getWarpCore } from '../../context/context';
import LinkIcon from '../../images/icons/external-link-icon.svg';
import { formatTimestamp } from '../../utils/date';
import { getHypExplorerLink } from '../../utils/links';
@ -21,11 +21,7 @@ import {
isTransferFailed,
isTransferSent,
} from '../../utils/transfer';
import { getChainReference } from '../caip/chains';
import { AssetNamespace, parseCaip19Id } from '../caip/tokens';
import { getChainDisplayName, hasPermissionlessChain } from '../chains/utils';
import { getMultiProvider } from '../multiProvider';
import { getToken } from '../tokens/metadata';
import { useAccountForChain } from '../wallet/hooks/multiProtocol';
import { TransferContext, TransferStatus } from './types';
@ -43,33 +39,38 @@ export function TransfersDetailsModal({
const [toUrl, setToUrl] = useState<string>('');
const [originTxUrl, setOriginTxUrl] = useState<string>('');
const { params, status, originTxHash, msgId, timestamp, activeAccountAddress } = transfer || {};
const { destinationCaip2Id, originCaip2Id, tokenCaip19Id, amount, recipientAddress } =
params || {};
const {
status,
origin,
destination,
amount,
sender,
recipient,
originTokenAddressOrDenom,
originTxHash,
msgId,
timestamp,
} = transfer || {};
const account = useAccountForChain(originCaip2Id);
const account = useAccountForChain(origin);
const multiProvider = getMultiProvider();
const originChain = getChainReference(originCaip2Id);
const destChain = getChainReference(destinationCaip2Id);
const { address: tokenAddress, namespace: tokenNamespace } = parseCaip19Id(tokenCaip19Id);
const isNative = tokenNamespace === AssetNamespace.native || isZeroishAddress(tokenAddress);
const getMessageUrls = useCallback(async () => {
try {
if (originTxHash) {
const originTxUrl = multiProvider.tryGetExplorerTxUrl(originChain, { hash: originTxHash });
const originTxUrl = multiProvider.tryGetExplorerTxUrl(origin, { hash: originTxHash });
if (originTxUrl) setOriginTxUrl(fixDoubleSlash(originTxUrl));
}
const [fromUrl, toUrl] = await Promise.all([
multiProvider.tryGetExplorerAddressUrl(originChain, activeAccountAddress),
multiProvider.tryGetExplorerAddressUrl(destChain, recipientAddress),
multiProvider.tryGetExplorerAddressUrl(origin, sender),
multiProvider.tryGetExplorerAddressUrl(destination, recipient),
]);
if (fromUrl) setFromUrl(fixDoubleSlash(fromUrl));
if (toUrl) setToUrl(fixDoubleSlash(toUrl));
} catch (error) {
logger.error('Error fetching URLs:', error);
}
}, [activeAccountAddress, originTxHash, multiProvider, recipientAddress, originChain, destChain]);
}, [sender, recipient, originTxHash, multiProvider, origin, destination]);
useEffect(() => {
if (!transfer) return;
@ -80,9 +81,9 @@ export function TransfersDetailsModal({
const isAccountReady = !!account?.isReady;
const connectorName = account?.connectorName || 'wallet';
const token = getToken(tokenCaip19Id);
const token = getWarpCore().findToken(origin, originTokenAddressOrDenom);
const isPermissionlessRoute = hasPermissionlessChain([destinationCaip2Id, originCaip2Id]);
const isPermissionlessRoute = hasPermissionlessChain([destination, origin]);
const isSent = isTransferSent(status);
const isFailed = isTransferFailed(status);
@ -100,7 +101,7 @@ export function TransfersDetailsModal({
[timestamp],
);
const explorerLink = getHypExplorerLink(originCaip2Id, msgId);
const explorerLink = getHypExplorerLink(origin, msgId);
return (
<Modal
@ -135,16 +136,15 @@ export function TransfersDetailsModal({
<TokenIcon token={token} size={30} />
<div className="ml-2 flex items items-baseline">
<span className="text-xl font-medium">{amount}</span>
<span className="text-xl font-medium ml-1">{token?.symbol || ''}</span>
<span className="font-semibold ml-1">({toTitleCase(tokenNamespace)})</span>
<span className="text-xl font-medium ml-1">{token?.symbol}</span>
</div>
</div>
<div className="mt-4 flex items-center justify-around">
<div className="ml-2 flex flex-col items-center">
<ChainLogo chainCaip2Id={originCaip2Id} size={64} background={true} />
<ChainLogo chainName={origin} size={64} background={true} />
<span className="mt-1 font-medium tracking-wider">
{getChainDisplayName(originCaip2Id, true)}
{getChainDisplayName(origin, true)}
</span>
</div>
<div className="flex mb-6 sm:space-x-1.5">
@ -152,18 +152,20 @@ export function TransfersDetailsModal({
<WideChevron />
</div>
<div className="mr-2 flex flex-col items-center">
<ChainLogo chainCaip2Id={destinationCaip2Id} size={64} background={true} />
<ChainLogo chainName={destination} size={64} background={true} />
<span className="mt-1 font-medium tracking-wider">
{getChainDisplayName(destinationCaip2Id, true)}
{getChainDisplayName(destination, true)}
</span>
</div>
</div>
{isFinal ? (
<div className="mt-5 flex flex-col space-y-4">
<TransferProperty name="Sender Address" value={activeAccountAddress} url={fromUrl} />
<TransferProperty name="Recipient Address" value={recipientAddress} url={toUrl} />
{!isNative && <TransferProperty name="Token Address" value={tokenAddress} />}
<TransferProperty name="Sender Address" value={sender} url={fromUrl} />
<TransferProperty name="Recipient Address" value={recipient} url={toUrl} />
{token?.addressOrDenom && (
<TransferProperty name="Token Address" value={token.addressOrDenom} />
)}
{originTxHash && (
<TransferProperty
name="Origin Transaction Hash"

@ -0,0 +1,45 @@
import { useMutation } from '@tanstack/react-query';
import { TokenAmount } from '@hyperlane-xyz/sdk';
import { ProtocolType, timeout } from '@hyperlane-xyz/utils';
import { getWarpCore } from '../../context/context';
import { logger } from '../../utils/logger';
import { getAccountAddressAndPubKey } from '../wallet/hooks/multiProtocol';
import { AccountInfo } from '../wallet/hooks/types';
const MAX_FETCH_TIMEOUT = 3000; // 3 seconds
interface FetchMaxParams {
accounts: Record<ProtocolType, AccountInfo>;
balance: TokenAmount;
origin: ChainName;
destination: ChainName;
}
export function useFetchMaxAmount() {
const mutation = useMutation({
mutationFn: (params: FetchMaxParams) => fetchMaxAmount(params),
});
return { fetchMaxAmount: mutation.mutateAsync, isLoading: mutation.isLoading };
}
async function fetchMaxAmount({ accounts, balance, destination, origin }: FetchMaxParams) {
try {
const { address, publicKey } = getAccountAddressAndPubKey(origin, accounts);
if (!address) return balance;
const maxAmount = await timeout(
getWarpCore().getMaxTransferAmount({
balance,
destination,
sender: address,
senderPubKey: await publicKey,
}),
MAX_FETCH_TIMEOUT,
);
return maxAmount;
} catch (error) {
logger.warn('Error or timeout fetching fee quotes for max amount', error);
return balance;
}
}

@ -1,19 +1,16 @@
import type { Route } from '../routes/types';
export interface TransferFormValues {
originCaip2Id: ChainCaip2Id;
destinationCaip2Id: ChainCaip2Id;
tokenCaip19Id: TokenCaip19Id;
origin: ChainName;
destination: ChainName;
tokenIndex: number | undefined;
amount: string;
recipientAddress: Address;
recipient: Address;
}
export enum TransferStatus {
Preparing = 'preparing',
CreatingApprove = 'creating-approve',
CreatingTxs = 'creating-txs',
SigningApprove = 'signing-approve',
ConfirmingApprove = 'confirming-approve',
CreatingTransfer = 'creating-transfer',
SigningTransfer = 'signing-transfer',
ConfirmingTransfer = 'confirming-transfer',
ConfirmedTransfer = 'confirmed-transfer',
@ -28,30 +25,14 @@ export const FinalTransferStatuses = [...SentTransferStatuses, TransferStatus.Fa
export interface TransferContext {
status: TransferStatus;
route: Route;
params: TransferFormValues;
origin: ChainName;
destination: ChainName;
originTokenAddressOrDenom?: string;
destTokenAddressOrDenom?: string;
amount: string;
sender: Address;
recipient: Address;
originTxHash?: string;
msgId?: string;
timestamp: number;
activeAccountAddress: Address;
}
export enum IgpTokenType {
NativeSeparate = 'native-separate', // Paying with origin chain native token
NativeCombined = 'native-combined', // Both igp fees and transfer token are native
TokenSeparate = 'token-separate', // Paying with a different non-native token
TokenCombined = 'token-combined', // Paying with the same token being transferred
}
export interface IgpQuote {
type: IgpTokenType;
amount: string;
weiAmount: string;
originCaip2Id: ChainCaip2Id;
destinationCaip2Id: ChainCaip2Id;
token: {
tokenCaip19Id: TokenCaip19Id;
symbol: string;
decimals: number;
};
}

@ -0,0 +1,27 @@
import { useEffect, useRef } from 'react';
import { toast } from 'react-toastify';
import { TokenAmount } from '@hyperlane-xyz/sdk';
export function useRecipientBalanceWatcher(recipient?: Address, balance?: TokenAmount) {
// A crude way to detect transfer completions by triggering
// toast on recipient balance increase. This is not ideal because it
// could confuse unrelated balance changes for message delivery
// TODO replace with a polling worker that queries the hyperlane explorer
const prevRecipientBalance = useRef<{ balance?: TokenAmount; recipient?: string }>({
recipient: '',
});
useEffect(() => {
if (
recipient &&
balance &&
prevRecipientBalance.current.balance &&
prevRecipientBalance.current.recipient === recipient &&
balance.token.equals(prevRecipientBalance.current.balance.token) &&
balance.amount > prevRecipientBalance.current.balance.amount
) {
toast.success('Recipient has received funds, transfer complete!');
}
prevRecipientBalance.current = { balance, recipient: recipient };
}, [balance, recipient, prevRecipientBalance]);
}

@ -0,0 +1,46 @@
import { useQuery } from '@tanstack/react-query';
import { TokenAmount } from '@hyperlane-xyz/sdk';
import { HexString } from '@hyperlane-xyz/utils';
import { getTokenByIndex, getWarpCore } from '../../context/context';
import { logger } from '../../utils/logger';
import { getAccountAddressAndPubKey, useAccounts } from '../wallet/hooks/multiProtocol';
import { TransferFormValues } from './types';
const FEE_QUOTE_REFRESH_INTERVAL = 15_000; // 10s
export function useFeeQuotes(
{ origin, destination, tokenIndex }: TransferFormValues,
enabled: boolean,
) {
const { accounts } = useAccounts();
const { address: sender, publicKey: senderPubKey } = getAccountAddressAndPubKey(origin, accounts);
const { isLoading, isError, data } = useQuery({
queryKey: ['useFeeQuotes', destination, tokenIndex, sender],
queryFn: () => fetchFeeQuotes(destination, tokenIndex, sender, senderPubKey),
enabled,
refetchInterval: FEE_QUOTE_REFRESH_INTERVAL,
});
return { isLoading, isError, fees: data };
}
async function fetchFeeQuotes(
destination?: ChainName,
tokenIndex?: number,
sender?: Address,
senderPubKey?: Promise<HexString>,
): Promise<{ interchainQuote: TokenAmount; localQuote: TokenAmount } | null> {
const originToken = getTokenByIndex(tokenIndex);
if (!destination || !sender || !originToken) return null;
logger.debug('Fetching fee quotes');
return getWarpCore().estimateTransferRemoteFees({
originToken,
destination,
sender,
senderPubKey: await senderPubKey,
});
}

@ -1,118 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { IHypTokenAdapter } from '@hyperlane-xyz/sdk';
import { ProtocolType, fromWei, isAddress } from '@hyperlane-xyz/utils';
import { useToastError } from '../../components/toast/useToastError';
import { DEFAULT_IGP_QUOTES } from '../../consts/igpQuotes';
import { getChainReference, parseCaip2Id } from '../caip/chains';
import { AssetNamespace, getCaip19Id, getNativeTokenAddress } from '../caip/tokens';
import { getChainMetadata, getMultiProvider } from '../multiProvider';
import { Route } from '../routes/types';
import {
isIbcOnlyRoute,
isIbcToWarpRoute,
isRouteFromCollateral,
isRouteFromNative,
} from '../routes/utils';
import { useStore } from '../store';
import { AdapterFactory } from '../tokens/AdapterFactory';
import { findTokensByAddress, getToken } from '../tokens/metadata';
import { IgpQuote, IgpTokenType } from './types';
export function useIgpQuote(route?: Route) {
const setIgpQuote = useStore((state) => state.setIgpQuote);
const { isLoading, isError, error, data } = useQuery({
queryKey: ['useIgpQuote', route],
queryFn: () => {
if (!route || isIbcOnlyRoute(route)) return null;
return fetchIgpQuote(route);
},
});
useEffect(() => {
setIgpQuote(data || null);
}, [data, setIgpQuote]);
useToastError(error, 'Error fetching IGP quote');
return { isLoading, isError, igpQuote: data };
}
export async function fetchIgpQuote(route: Route, adapter?: IHypTokenAdapter): Promise<IgpQuote> {
const { baseTokenCaip19Id, originCaip2Id, destCaip2Id: destinationCaip2Id } = route;
const { protocol: originProtocol, reference: originChainId } = parseCaip2Id(originCaip2Id);
const baseToken = getToken(baseTokenCaip19Id);
if (!baseToken) throw new Error(`No base token found for ${baseTokenCaip19Id}`);
let weiAmount: string;
const defaultQuotes = DEFAULT_IGP_QUOTES[originProtocol];
if (typeof defaultQuotes === 'string') {
weiAmount = defaultQuotes;
} else if (defaultQuotes?.[originChainId]) {
weiAmount = defaultQuotes[originChainId];
} else {
// Otherwise, compute IGP quote via the adapter
adapter ||= AdapterFactory.HypTokenAdapterFromRouteOrigin(route);
const destinationChainId = getChainReference(destinationCaip2Id);
const destinationDomainId = getMultiProvider().getDomainId(destinationChainId);
weiAmount = await adapter.quoteGasPayment(destinationDomainId);
}
// Determine the IGP token
const isRouteFromBase = isRouteFromCollateral(route) || isIbcToWarpRoute(route);
let type: IgpTokenType;
let tokenCaip19Id: TokenCaip19Id;
let tokenSymbol: string;
let tokenDecimals: number;
// If the token has an explicit IGP token address set, use that
// Custom igpTokenAddress configs are supported only from the base (i.e. collateral) token is supported atm
if (
isRouteFromBase &&
baseToken.igpTokenAddressOrDenom &&
isAddress(baseToken.igpTokenAddressOrDenom)
) {
type = IgpTokenType.TokenSeparate;
const igpToken = findTokensByAddress(baseToken.igpTokenAddressOrDenom)[0];
tokenCaip19Id = igpToken.tokenCaip19Id;
// Note this assumes the u prefix because only cosmos tokens use this case
tokenSymbol = igpToken.symbol;
tokenDecimals = igpToken.decimals;
} else if (originProtocol === ProtocolType.Cosmos) {
// TODO Handle case of an evm-based token warped to cosmos
if (!isRouteFromBase) throw new Error('IGP quote for cosmos synthetics not yet supported');
// If the protocol is cosmos, use the base token
type = IgpTokenType.TokenCombined;
tokenCaip19Id = baseToken.tokenCaip19Id;
tokenSymbol = baseToken.symbol;
tokenDecimals = baseToken.decimals;
} else {
// Otherwise use the plain old native token from the route origin
type = isRouteFromNative(route) ? IgpTokenType.NativeCombined : IgpTokenType.NativeSeparate;
const originNativeToken = getChainMetadata(originCaip2Id).nativeToken;
if (!originNativeToken) throw new Error(`No native token for ${originCaip2Id}`);
tokenCaip19Id = getCaip19Id(
originCaip2Id,
AssetNamespace.native,
getNativeTokenAddress(originProtocol),
);
tokenSymbol = originNativeToken.symbol;
tokenDecimals = originNativeToken.decimals;
}
return {
type,
amount: fromWei(weiAmount, tokenDecimals),
weiAmount,
originCaip2Id,
destinationCaip2Id,
token: {
tokenCaip19Id,
symbol: tokenSymbol,
decimals: tokenDecimals,
},
};
}

@ -1,49 +1,21 @@
import { MsgTransferEncodeObject } from '@cosmjs/stargate';
import type { Transaction as SolTransaction } from '@solana/web3.js';
import {
SendTransactionArgs as ViemTransactionRequest,
WaitForTransactionResult as ViemViemTransactionReceipt,
} from '@wagmi/core';
import BigNumber from 'bignumber.js';
import { PopulatedTransaction as Ethers5Transaction } from 'ethers';
import { useCallback, useState } from 'react';
import { toast } from 'react-toastify';
import {
CosmIbcToWarpTokenAdapter,
CosmIbcTokenAdapter,
IHypTokenAdapter,
} from '@hyperlane-xyz/sdk';
import { ProtocolType, toWei } from '@hyperlane-xyz/utils';
import { WarpTxCategory } from '@hyperlane-xyz/sdk';
import { toTitleCase, toWei } from '@hyperlane-xyz/utils';
import { toastTxSuccess } from '../../components/toast/TxSuccessToast';
import { getTokenByIndex, getWarpCore } from '../../context/context';
import { logger } from '../../utils/logger';
import { parseCaip2Id } from '../caip/chains';
import { isNonFungibleToken } from '../caip/tokens';
import { getChainMetadata, getMultiProvider } from '../multiProvider';
import { Route, RoutesMap } from '../routes/types';
import { getTokenRoute, isIbcOnlyRoute, isIbcRoute, isWarpRoute } from '../routes/utils';
import { AppState, useStore } from '../store';
import { AdapterFactory } from '../tokens/AdapterFactory';
import { isApproveRequired } from '../tokens/approval';
import {
getAccountAddressForChain,
useAccounts,
useActiveChains,
useTransactionFns,
} from '../wallet/hooks/multiProtocol';
import { ActiveChainInfo, SendTransactionFn } from '../wallet/hooks/types';
import { ethers5TxToWagmiTx } from '../wallet/utils';
import {
IgpQuote,
IgpTokenType,
TransferContext,
TransferFormValues,
TransferStatus,
} from './types';
import { fetchIgpQuote } from './useIgpQuote';
import { ensureSufficientCollateral, tryGetMsgIdFromEvmTransferReceipt } from './utils';
import { TransferContext, TransferFormValues, TransferStatus } from './types';
export function useTokenTransfer(onDone?: () => void) {
const { transfers, addTransfer, updateTransferStatus } = useStore((s) => ({
@ -61,10 +33,9 @@ export function useTokenTransfer(onDone?: () => void) {
// TODO implement cancel callback for when modal is closed?
const triggerTransactions = useCallback(
(values: TransferFormValues, tokenRoutes: RoutesMap) =>
(values: TransferFormValues) =>
executeTransfer({
values,
tokenRoutes,
transferIndex,
activeAccounts,
activeChains,
@ -94,7 +65,6 @@ export function useTokenTransfer(onDone?: () => void) {
async function executeTransfer({
values,
tokenRoutes,
transferIndex,
activeAccounts,
activeChains,
@ -105,7 +75,6 @@ async function executeTransfer({
onDone,
}: {
values: TransferFormValues;
tokenRoutes: RoutesMap;
transferIndex: number;
activeAccounts: ReturnType<typeof useAccounts>;
activeChains: ReturnType<typeof useActiveChains>;
@ -117,74 +86,89 @@ async function executeTransfer({
}) {
logger.debug('Preparing transfer transaction(s)');
setIsLoading(true);
let status: TransferStatus = TransferStatus.Preparing;
let transferStatus: TransferStatus = TransferStatus.Preparing;
updateTransferStatus(transferIndex, transferStatus);
try {
const { originCaip2Id, destinationCaip2Id, tokenCaip19Id, amount, recipientAddress } = values;
const { protocol: originProtocol } = parseCaip2Id(originCaip2Id);
const { reference: destReference } = parseCaip2Id(destinationCaip2Id);
const destinationDomainId = getMultiProvider().getDomainId(destReference);
const { origin, destination, tokenIndex, amount, recipient } = values;
const originToken = getTokenByIndex(tokenIndex);
const connection = originToken?.getConnectionForChain(destination);
if (!originToken || !connection) throw new Error('No token route found between chains');
const tokenRoute = getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes);
if (!tokenRoute) throw new Error('No token route found between chains');
const originProtocol = originToken.protocol;
const isNft = originToken.isNft();
const weiAmountOrId = isNft ? amount : toWei(amount, originToken.decimals);
const originTokenAmount = originToken.amount(weiAmountOrId);
const isNft = isNonFungibleToken(tokenCaip19Id);
const weiAmountOrId = isNft ? amount : toWei(amount, tokenRoute.originDecimals);
const activeAccountAddress = getAccountAddressForChain(
originCaip2Id,
activeAccounts.accounts[originProtocol],
);
if (!activeAccountAddress) throw new Error('No active account found for origin chain');
const sendTransaction = transactionFns[originProtocol].sendTransaction;
const activeChain = activeChains.chains[originProtocol];
const sender = getAccountAddressForChain(origin, activeAccounts.accounts);
if (!sender) throw new Error('No active account found for origin chain');
const warpCore = getWarpCore();
const isCollateralSufficient = await warpCore.isDestinationCollateralSufficient({
originTokenAmount,
destination,
});
if (!isCollateralSufficient) {
toast.error('Insufficient collateral on destination for transfer');
throw new Error('Insufficient destination collateral');
}
addTransfer({
activeAccountAddress,
timestamp: new Date().getTime(),
status,
route: tokenRoute,
params: values,
status: TransferStatus.Preparing,
origin,
destination,
originTokenAddressOrDenom: originToken.addressOrDenom,
destTokenAddressOrDenom: connection.token.addressOrDenom,
sender,
recipient,
amount,
});
const executeParams: ExecuteTransferParams<any, any> = {
weiAmountOrId,
originProtocol,
destinationDomainId,
recipientAddress,
tokenRoute,
activeAccountAddress,
activeChain,
updateStatus: (s: TransferStatus) => {
status = s;
updateTransferStatus(transferIndex, s);
},
sendTransaction: transactionFns[originProtocol].sendTransaction,
};
updateTransferStatus(transferIndex, (transferStatus = TransferStatus.CreatingTxs));
let transferTxHash: string;
let msgId: string | undefined;
if (isWarpRoute(tokenRoute)) {
({ transferTxHash, msgId } = await executeHypTransfer(executeParams));
} else if (isIbcRoute(tokenRoute)) {
({ transferTxHash } = await executeIbcTransfer(executeParams));
} else {
throw new Error('Unsupported route type');
const txs = await warpCore.getTransferRemoteTxs({
originTokenAmount,
destination,
sender,
recipient,
});
const hashes: string[] = [];
for (const tx of txs) {
updateTransferStatus(transferIndex, (transferStatus = txCategoryToStatuses[tx.category][0]));
const { hash, confirm } = await sendTransaction({
tx: tx.transaction,
chainName: origin,
activeChainName: activeChain.chainName,
providerType: tx.type,
});
updateTransferStatus(transferIndex, (transferStatus = txCategoryToStatuses[tx.category][1]));
const receipt = await confirm();
const description = toTitleCase(tx.category);
logger.debug(`${description} transaction confirmed, hash:`, receipt.transactionHash);
toastTxSuccess(`${description} transaction sent!`, receipt.transactionHash, origin);
hashes.push(hash);
}
updateTransferStatus(transferIndex, (status = TransferStatus.ConfirmedTransfer), {
originTxHash: transferTxHash,
msgId,
});
// TODO
// const msgId = tryGetMsgIdFromTransferReceipt(transferReceipt);
logger.debug('Transfer transaction confirmed, hash:', transferTxHash);
toastTxSuccess('Remote transfer started!', transferTxHash, originCaip2Id);
updateTransferStatus(transferIndex, (transferStatus = TransferStatus.ConfirmedTransfer), {
originTxHash: hashes.at(-1),
msgId: '',
});
} catch (error) {
logger.error(`Error at stage ${status}`, error);
logger.error(`Error at stage ${transferStatus}`, error);
updateTransferStatus(transferIndex, TransferStatus.Failed);
if (JSON.stringify(error).includes('ChainMismatchError')) {
// Wagmi switchNetwork call helps prevent this but isn't foolproof
toast.error('Wallet must be connected to origin chain');
} else {
toast.error(errorMessages[status] || 'Unable to transfer tokens.');
toast.error(errorMessages[transferStatus] || 'Unable to transfer tokens.');
}
}
@ -192,261 +176,16 @@ async function executeTransfer({
if (onDone) onDone();
}
interface ExecuteTransferParams<TxReq, TxResp> {
weiAmountOrId: string;
originProtocol: ProtocolType;
destinationDomainId: DomainId;
recipientAddress: Address;
tokenRoute: Route;
activeAccountAddress: Address;
activeChain: ActiveChainInfo;
updateStatus: (s: TransferStatus) => void;
sendTransaction: SendTransactionFn<TxReq, TxResp>;
}
interface ExecuteHypTransferParams<TxReq, TxResp> extends ExecuteTransferParams<TxReq, TxResp> {
hypTokenAdapter: IHypTokenAdapter;
igpQuote: IgpQuote;
}
async function executeHypTransfer(params: ExecuteTransferParams<any, any>) {
const { tokenRoute, weiAmountOrId, originProtocol } = params;
const hypTokenAdapter = AdapterFactory.HypTokenAdapterFromRouteOrigin(tokenRoute);
await ensureSufficientCollateral(tokenRoute, weiAmountOrId);
const igpQuote = await fetchIgpQuote(tokenRoute, hypTokenAdapter);
const hypTransferParams: ExecuteHypTransferParams<any, any> = {
...params,
hypTokenAdapter,
igpQuote,
};
let result: { transferTxHash: string; msgId?: string };
if (originProtocol === ProtocolType.Ethereum) {
result = await executeEvmTransfer(hypTransferParams);
} else if (originProtocol === ProtocolType.Sealevel) {
result = await executeSealevelTransfer(hypTransferParams);
} else if (originProtocol === ProtocolType.Cosmos) {
result = await executeCosmWasmTransfer(hypTransferParams);
} else {
throw new Error(`Unsupported protocol type: ${originProtocol}`);
}
return result;
}
async function executeEvmTransfer({
weiAmountOrId,
destinationDomainId,
recipientAddress,
tokenRoute,
hypTokenAdapter,
igpQuote,
activeAccountAddress,
activeChain,
updateStatus,
sendTransaction,
}: ExecuteHypTransferParams<ViemTransactionRequest, ViemViemTransactionReceipt>) {
if (!isWarpRoute(tokenRoute)) throw new Error('Unsupported route type');
const { baseRouterAddress, originCaip2Id, baseTokenCaip19Id } = tokenRoute;
const isApproveTxRequired =
activeAccountAddress &&
(await isApproveRequired(tokenRoute, baseTokenCaip19Id, weiAmountOrId, activeAccountAddress));
if (isApproveTxRequired) {
updateStatus(TransferStatus.CreatingApprove);
const tokenAdapter = AdapterFactory.TokenAdapterFromAddress(baseTokenCaip19Id);
const approveTxRequest = (await tokenAdapter.populateApproveTx({
weiAmountOrId,
recipient: baseRouterAddress,
})) as Ethers5Transaction;
updateStatus(TransferStatus.SigningApprove);
const { confirm: confirmApprove } = await sendTransaction({
tx: ethers5TxToWagmiTx(approveTxRequest),
chainCaip2Id: originCaip2Id,
activeCap2Id: activeChain.chainCaip2Id,
});
updateStatus(TransferStatus.ConfirmingApprove);
const approveTxReceipt = await confirmApprove();
logger.debug('Approve transaction confirmed, hash:', approveTxReceipt.transactionHash);
toastTxSuccess('Approve transaction sent!', approveTxReceipt.transactionHash, originCaip2Id);
}
updateStatus(TransferStatus.CreatingTransfer);
logger.debug('Quoted gas payment', igpQuote.weiAmount);
// If sending native tokens (e.g. Eth), the gasPayment must be added to the tx value and sent together
const txValue =
igpQuote.type === IgpTokenType.NativeCombined
? BigNumber(igpQuote.weiAmount).plus(weiAmountOrId).toFixed(0)
: igpQuote.weiAmount;
const transferTxRequest = (await hypTokenAdapter.populateTransferRemoteTx({
weiAmountOrId: weiAmountOrId.toString(),
recipient: recipientAddress,
destination: destinationDomainId,
txValue,
})) as Ethers5Transaction;
updateStatus(TransferStatus.SigningTransfer);
const { hash: transferTxHash, confirm: confirmTransfer } = await sendTransaction({
tx: ethers5TxToWagmiTx(transferTxRequest),
chainCaip2Id: originCaip2Id,
activeCap2Id: activeChain.chainCaip2Id,
});
updateStatus(TransferStatus.ConfirmingTransfer);
const transferReceipt = await confirmTransfer();
const msgId = tryGetMsgIdFromEvmTransferReceipt(transferReceipt);
return { transferTxHash, msgId };
}
async function executeSealevelTransfer({
weiAmountOrId,
destinationDomainId,
recipientAddress,
tokenRoute,
hypTokenAdapter,
activeAccountAddress,
activeChain,
updateStatus,
sendTransaction,
}: ExecuteHypTransferParams<SolTransaction, void>) {
const { originCaip2Id } = tokenRoute;
updateStatus(TransferStatus.CreatingTransfer);
// TODO solana enable gas payments?
// logger.debug('Quoted gas payment', igpQuote.weiAmount);
const transferTxRequest = (await hypTokenAdapter.populateTransferRemoteTx({
weiAmountOrId,
destination: destinationDomainId,
recipient: recipientAddress,
fromAccountOwner: activeAccountAddress,
})) as SolTransaction;
updateStatus(TransferStatus.SigningTransfer);
const { hash: transferTxHash, confirm: confirmTransfer } = await sendTransaction({
tx: transferTxRequest,
chainCaip2Id: originCaip2Id,
activeCap2Id: activeChain.chainCaip2Id,
});
updateStatus(TransferStatus.ConfirmingTransfer);
await confirmTransfer();
return { transferTxHash };
}
async function executeCosmWasmTransfer({
weiAmountOrId,
destinationDomainId,
recipientAddress,
tokenRoute,
hypTokenAdapter,
igpQuote,
activeChain,
updateStatus,
sendTransaction,
}: ExecuteHypTransferParams<any, void>) {
updateStatus(TransferStatus.CreatingTransfer);
const transferTxRequest = await hypTokenAdapter.populateTransferRemoteTx({
weiAmountOrId,
recipient: recipientAddress,
destination: destinationDomainId,
txValue: igpQuote.weiAmount,
});
updateStatus(TransferStatus.SigningTransfer);
const { hash: transferTxHash, confirm: confirmTransfer } = await sendTransaction({
tx: { type: 'cosmwasm', request: transferTxRequest },
chainCaip2Id: tokenRoute.originCaip2Id,
activeCap2Id: activeChain.chainCaip2Id,
});
updateStatus(TransferStatus.ConfirmingTransfer);
await confirmTransfer();
return { transferTxHash };
}
async function executeIbcTransfer({
weiAmountOrId,
destinationDomainId,
recipientAddress,
tokenRoute,
activeChain,
activeAccountAddress,
updateStatus,
sendTransaction,
}: ExecuteTransferParams<any, void>) {
if (!isIbcRoute(tokenRoute)) throw new Error('Unsupported route type');
updateStatus(TransferStatus.CreatingTransfer);
const multiProvider = getMultiProvider();
const chainName = getChainMetadata(tokenRoute.originCaip2Id).name;
const adapterProperties = {
ibcDenom: tokenRoute.originIbcDenom,
sourcePort: tokenRoute.sourcePort,
sourceChannel: tokenRoute.sourceChannel,
};
let adapter: IHypTokenAdapter;
let txValue: string | undefined = undefined;
if (isIbcOnlyRoute(tokenRoute)) {
adapter = new CosmIbcTokenAdapter(chainName, multiProvider, {}, adapterProperties);
} else {
const intermediateChainName = getChainMetadata(tokenRoute.intermediateCaip2Id).name;
adapter = new CosmIbcToWarpTokenAdapter(
chainName,
multiProvider,
{
intermediateRouterAddress: tokenRoute.intermediateRouterAddress,
destinationRouterAddress: tokenRoute.destRouterAddress,
},
{
...adapterProperties,
derivedIbcDenom: tokenRoute.derivedIbcDenom,
intermediateChainName,
},
);
const igpQuote = await fetchIgpQuote(tokenRoute, adapter);
txValue = igpQuote.weiAmount;
}
const transferTxRequest = (await adapter.populateTransferRemoteTx({
weiAmountOrId,
recipient: recipientAddress,
fromAccountOwner: activeAccountAddress,
destination: destinationDomainId,
txValue,
})) as MsgTransferEncodeObject;
updateStatus(TransferStatus.SigningTransfer);
const { hash: transferTxHash, confirm: confirmTransfer } = await sendTransaction({
tx: { type: 'stargate', request: transferTxRequest },
chainCaip2Id: tokenRoute.originCaip2Id,
activeCap2Id: activeChain.chainCaip2Id,
});
updateStatus(TransferStatus.ConfirmingTransfer);
await confirmTransfer();
return { transferTxHash };
}
const errorMessages: Partial<Record<TransferStatus, string>> = {
[TransferStatus.Preparing]: 'Error while preparing the transactions.',
[TransferStatus.CreatingApprove]: 'Error while creating the approve transaction.',
[TransferStatus.CreatingTxs]: 'Error while creating the transactions.',
[TransferStatus.SigningApprove]: 'Error while signing the approve transaction.',
[TransferStatus.ConfirmingApprove]: 'Error while confirming the approve transaction.',
[TransferStatus.CreatingTransfer]: 'Error while creating the transfer transaction.',
[TransferStatus.SigningTransfer]: 'Error while signing the transfer transaction.',
[TransferStatus.ConfirmingTransfer]: 'Error while confirming the transfer transaction.',
};
const txCategoryToStatuses: Record<WarpTxCategory, [TransferStatus, TransferStatus]> = {
[WarpTxCategory.Approval]: [TransferStatus.SigningApprove, TransferStatus.ConfirmingApprove],
[WarpTxCategory.Transfer]: [TransferStatus.SigningTransfer, TransferStatus.ConfirmingTransfer],
};

@ -1,46 +1,11 @@
import BigNumber from 'bignumber.js';
import { toast } from 'react-toastify';
import { TransactionReceipt } from 'viem';
import { HyperlaneCore } from '@hyperlane-xyz/sdk';
import { ProtocolType, convertDecimals } from '@hyperlane-xyz/utils';
import { logger } from '../../utils/logger';
import { getProtocolType } from '../caip/chains';
import { isNonFungibleToken } from '../caip/tokens';
import { Route } from '../routes/types';
import { isRouteToCollateral, isWarpRoute } from '../routes/utils';
import { AdapterFactory } from '../tokens/AdapterFactory';
// In certain cases, like when a synthetic token has >1 collateral tokens
// it's possible that the collateral contract balance is insufficient to
// cover the remote transfer. This ensures the balance is sufficient or throws.
export async function ensureSufficientCollateral(route: Route, weiAmount: string) {
if (!isRouteToCollateral(route) || isNonFungibleToken(route.baseTokenCaip19Id)) return;
// TODO cosmos support here
if (
getProtocolType(route.originCaip2Id) === ProtocolType.Cosmos ||
getProtocolType(route.destCaip2Id) === ProtocolType.Cosmos ||
!isWarpRoute(route)
)
return;
logger.debug('Ensuring collateral balance for route', route);
const adapter = AdapterFactory.HypTokenAdapterFromRouteDest(route);
const destinationBalance = await adapter.getBalance(route.destRouterAddress);
const destinationBalanceInOriginDecimals = convertDecimals(
route.destDecimals,
route.originDecimals,
destinationBalance,
);
if (new BigNumber(destinationBalanceInOriginDecimals).lt(weiAmount)) {
toast.error('Collateral contract balance insufficient for transfer');
throw new Error('Insufficient collateral balance');
}
}
export function tryGetMsgIdFromEvmTransferReceipt(receipt: TransactionReceipt) {
// TODO multiprotocol
export function tryGetMsgIdFromTransferReceipt(receipt: TransactionReceipt) {
try {
// TODO viem
// @ts-ignore

@ -1,223 +0,0 @@
import BigNumber from 'bignumber.js';
import { toast } from 'react-toastify';
import {
ProtocolType,
isValidAddress,
isZeroishAddress,
toWei,
tryParseAmount,
} from '@hyperlane-xyz/utils';
import { toastIgpDetails } from '../../components/toast/IgpDetailsToast';
import { config } from '../../consts/config';
import { logger } from '../../utils/logger';
import { getProtocolType } from '../caip/chains';
import { isNonFungibleToken, parseCaip19Id } from '../caip/tokens';
import { getChainMetadata } from '../multiProvider';
import { Route, RoutesMap } from '../routes/types';
import { getTokenRoute, isIbcOnlyRoute } from '../routes/utils';
import { AppState } from '../store';
import { AdapterFactory } from '../tokens/AdapterFactory';
import { getToken } from '../tokens/metadata';
import { getAccountAddressForChain } from '../wallet/hooks/multiProtocol';
import { AccountInfo } from '../wallet/hooks/types';
import { IgpQuote, IgpTokenType, TransferFormValues } from './types';
type FormError = Partial<Record<keyof TransferFormValues, string>>;
type Balances = AppState['balances'];
export async function validateFormValues(
values: TransferFormValues,
tokenRoutes: RoutesMap,
balances: Balances,
igpQuote: IgpQuote | null,
accounts: Record<ProtocolType, AccountInfo>,
): Promise<FormError> {
const { originCaip2Id, destinationCaip2Id, amount, tokenCaip19Id, recipientAddress } = values;
const route = getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes);
if (!route) return { destinationCaip2Id: 'No route found for chains/token' };
const chainError = validateChains(originCaip2Id, destinationCaip2Id);
if (chainError) return chainError;
const tokenError = validateToken(tokenCaip19Id);
if (tokenError) return tokenError;
const recipientError = validateRecipient(recipientAddress, destinationCaip2Id);
if (recipientError) return recipientError;
const isNft = isNonFungibleToken(tokenCaip19Id);
const { error: amountError, parsedAmount } = validateAmount(amount, isNft);
if (amountError) return amountError;
if (isNft) {
const balancesError = validateNftBalances(balances, parsedAmount.toString());
if (balancesError) return balancesError;
} else {
const balancesError = await validateTokenBalances({
balances,
parsedAmount,
route,
igpQuote,
accounts,
});
if (balancesError) return balancesError;
}
return {};
}
function validateChains(
originCaip2Id: ChainCaip2Id,
destinationCaip2Id: ChainCaip2Id,
): FormError | null {
if (!originCaip2Id) return { originCaip2Id: 'Invalid origin chain' };
if (!destinationCaip2Id) return { destinationCaip2Id: 'Invalid destination chain' };
if (
config.withdrawalWhitelist &&
!config.withdrawalWhitelist.split(',').includes(destinationCaip2Id)
) {
return { destinationCaip2Id: 'Bridge is in deposit-only mode' };
}
if (
config.transferBlacklist &&
config.transferBlacklist.split(',').includes(`${originCaip2Id}-${destinationCaip2Id}`)
) {
return { destinationCaip2Id: 'Route is not currently allowed' };
}
return null;
}
function validateToken(tokenCaip19Id: TokenCaip19Id): FormError | null {
if (!tokenCaip19Id) return { tokenCaip19Id: 'Token required' };
const { address: tokenAddress } = parseCaip19Id(tokenCaip19Id);
const tokenMetadata = getToken(tokenCaip19Id);
if (!tokenMetadata || (!isZeroishAddress(tokenAddress) && !isValidAddress(tokenAddress))) {
return { tokenCaip19Id: 'Invalid token' };
}
return null;
}
function validateRecipient(
recipientAddress: Address,
destinationCaip2Id: ChainCaip2Id,
): FormError | null {
const destProtocol = getProtocolType(destinationCaip2Id);
// Ensure recip address is valid for the destination chain's protocol
if (!isValidAddress(recipientAddress, destProtocol))
return { recipientAddress: 'Invalid recipient' };
// Also ensure the address denom is correct if the dest protocol is Cosmos
if (destProtocol === ProtocolType.Cosmos) {
const destChainPrefix = getChainMetadata(destinationCaip2Id).bech32Prefix;
if (!destChainPrefix) {
toast.error(`No bech32 prefix found for chain ${destinationCaip2Id}`);
return { destinationCaip2Id: 'Invalid chain data' };
} else if (!recipientAddress.startsWith(destChainPrefix)) {
toast.error(`Recipient address prefix should be ${destChainPrefix}`);
return { recipientAddress: `Invalid recipient prefix` };
}
}
return null;
}
function validateAmount(
amount: string,
isNft: boolean,
): { parsedAmount: BigNumber; error: FormError | null } {
const parsedAmount = tryParseAmount(amount);
if (!parsedAmount || parsedAmount.lte(0)) {
return {
parsedAmount: BigNumber(0),
error: { amount: isNft ? 'Invalid Token Id' : 'Invalid amount' },
};
}
return { parsedAmount, error: null };
}
// Validate balances for ERC721-like tokens
function validateNftBalances(balances: Balances, nftId: string | number): FormError | null {
const { isSenderNftOwner, senderNftIds } = balances;
if (isSenderNftOwner === false || (senderNftIds && !senderNftIds.includes(nftId.toString()))) {
return { amount: 'Token ID not owned' };
}
return null;
}
// Validate balances for ERC20-like tokens
async function validateTokenBalances({
balances,
parsedAmount,
route,
igpQuote,
accounts,
}: {
balances: Balances;
parsedAmount: BigNumber;
route: Route;
igpQuote: IgpQuote | null;
accounts: Record<ProtocolType, AccountInfo>;
}): Promise<FormError | null> {
const sendValue = new BigNumber(toWei(parsedAmount, route.originDecimals));
// First check basic token balance
if (sendValue.gt(balances.senderTokenBalance)) return { amount: 'Insufficient balance' };
// Next, ensure balances can cover IGP fees
// But not for pure IBC routes because IGP is not used
if (isIbcOnlyRoute(route)) return null;
if (!igpQuote?.weiAmount) return { amount: 'Interchain gas quote not ready' };
const { type: igpTokenType, amount: igpAmount, weiAmount: igpWeiAmount } = igpQuote;
const { symbol: igpTokenSymbol, tokenCaip19Id: igpTokenCaip19Id } = igpQuote.token;
let igpTokenBalance: string;
if ([IgpTokenType.NativeCombined, IgpTokenType.NativeSeparate].includes(igpTokenType)) {
igpTokenBalance = balances.senderNativeBalance;
} else if (igpTokenType === IgpTokenType.TokenCombined) {
igpTokenBalance = balances.senderTokenBalance;
} else if (igpTokenType === IgpTokenType.TokenSeparate) {
igpTokenBalance = await fetchSenderTokenBalance(
accounts,
route.originCaip2Id,
igpTokenCaip19Id,
);
} else {
return { amount: 'Interchain gas quote not valid' };
}
const requiredIgpTokenBalance = [
IgpTokenType.NativeCombined,
IgpTokenType.TokenCombined,
].includes(igpTokenType)
? sendValue.plus(igpWeiAmount)
: BigNumber(igpWeiAmount);
if (requiredIgpTokenBalance.gt(igpTokenBalance)) {
toastIgpDetails(igpAmount, igpTokenSymbol);
return { amount: `Insufficient ${igpTokenSymbol} for gas` };
}
return null;
}
async function fetchSenderTokenBalance(
accounts: Record<ProtocolType, AccountInfo>,
originCaip2Id: ChainCaip2Id,
igpTokenCaip19Id: TokenCaip19Id,
) {
try {
const account = accounts[getProtocolType(originCaip2Id)];
const sender = getAccountAddressForChain(originCaip2Id, account);
if (!sender) throw new Error('No sender address found');
const adapter = AdapterFactory.TokenAdapterFromAddress(igpTokenCaip19Id);
const igpTokenBalance = await adapter.getBalance(sender);
return igpTokenBalance;
} catch (error) {
logger.error('Error fetching token balance during form validation', error);
toast.error('Error fetching balance for validation');
throw error;
}
}

@ -2,12 +2,11 @@ import Image from 'next/image';
import { useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { toTitleCase } from '@hyperlane-xyz/utils';
import { SmallSpinner } from '../../components/animation/SmallSpinner';
import { ChainLogo } from '../../components/icons/ChainLogo';
import { Identicon } from '../../components/icons/Identicon';
import { PLACEHOLDER_COSMOS_CHAIN } from '../../consts/values';
import { getWarpCore } from '../../context/context';
import ArrowRightIcon from '../../images/icons/arrow-right.svg';
import CollapseIcon from '../../images/icons/collapse-icon.svg';
import Logout from '../../images/icons/logout.svg';
@ -15,14 +14,13 @@ import ResetIcon from '../../images/icons/reset-icon.svg';
import Wallet from '../../images/icons/wallet.svg';
import { tryClipboardSet } from '../../utils/clipboard';
import { STATUSES_WITH_ICON, getIconByTransferStatus } from '../../utils/transfer';
import { getAssetNamespace } from '../caip/tokens';
import { getChainDisplayName } from '../chains/utils';
import { useStore } from '../store';
import { getToken } from '../tokens/metadata';
import { TransfersDetailsModal } from '../transfer/TransfersDetailsModal';
import { TransferContext } from '../transfer/types';
import { useAccounts, useDisconnectFns } from './hooks/multiProtocol';
import { AccountInfo } from './hooks/types';
export function SideBarMenu({
onConnectWallet,
@ -59,12 +57,6 @@ export function SideBarMenu({
setIsMenuOpen(isOpen);
}, [isOpen]);
const onClickCopy = (value?: string) => async () => {
if (!value) return;
await tryClipboardSet(value);
toast.success('Address copied to clipboard', { autoClose: 2000 });
};
const onClickDisconnect = async () => {
for (const disconnectFn of Object.values(disconnects)) {
await disconnectFn();
@ -96,28 +88,10 @@ export function SideBarMenu({
Connected Wallets
</div>
<div className="my-3 px-3 space-y-3">
{readyAccounts.map((acc) =>
acc.addresses.map((addr) => {
if (addr?.chainCaip2Id?.includes(PLACEHOLDER_COSMOS_CHAIN)) return null;
return (
<button
key={addr.address}
onClick={onClickCopy(addr.address)}
className={`${styles.btn} border border-gray-200 rounded-xl`}
>
<div className="shrink-0">
<Identicon address={addr.address} size={40} />
</div>
<div className="flex flex-col mx-3 items-start">
<div className="text-gray-800 text-sm font-normal">
{acc.connectorName || 'Wallet'}
</div>
<div className="text-xs text-left truncate w-64">
{addr.address ? addr.address : 'Unknown'}
</div>
</div>
</button>
);
{readyAccounts.map((acc, i) =>
acc.addresses.map((addr, j) => {
if (addr?.chainName?.includes(PLACEHOLDER_COSMOS_CHAIN)) return null;
return <AccountSummary key={`${i}-${j}`} account={acc} address={addr.address} />;
}),
)}
<button onClick={onConnectWallet} className={styles.btn}>
@ -135,63 +109,15 @@ export function SideBarMenu({
<div className="flex grow flex-col px-3.5">
<div className="grow flex flex-col w-full">
{sortedTransfers?.length > 0 &&
sortedTransfers.map((t) => (
<button
key={t.timestamp}
sortedTransfers.map((t, i) => (
<TransferSummary
key={i}
transfer={t}
onClick={() => {
setSelectedTransfer(t);
setIsModalOpen(true);
}}
className="flex justify-between items-center rounded-xl border border-gray-200 px-2.5 py-2 mb-2.5 hover:bg-gray-200 active:bg-gray-300 transition-all duration-500"
>
<div className="flex">
<div className="mr-2.5 flex flex-col items-center justify-center rounded-full bg-gray-100 h-[2.25rem] w-[2.25rem] p-1.5">
<ChainLogo chainCaip2Id={t.params.originCaip2Id} size={20} />
</div>
<div className="flex flex-col">
<div className="flex flex-col">
<div className="flex items items-baseline">
<span className="text-gray-800 text-sm font-normal">
{t.params.amount}
</span>
<span className="text-gray-800 text-sm font-normal ml-1">
{getToken(t.params.tokenCaip19Id)?.symbol || ''}
</span>
<span className="text-black text-xs font-normal ml-1">
({toTitleCase(getAssetNamespace(t.params.tokenCaip19Id))})
</span>
</div>
<div className="mt-1 flex flex-row items-center">
<span className="text-thin text-gray-900 font-normal tracking-wide">
{getChainDisplayName(t.params.originCaip2Id, true)}
</span>
<Image
className="mx-1"
src={ArrowRightIcon}
width={10}
height={10}
alt=""
/>
<span className="text-thin text-gray-900 font-normal tracking-wide">
{getChainDisplayName(t.params.destinationCaip2Id, true)}
</span>
</div>
</div>
</div>
</div>
<div className="flex w-6 h-6">
{STATUSES_WITH_ICON.includes(t.status) ? (
<Image
src={getIconByTransferStatus(t.status)}
width={25}
height={25}
alt=""
/>
) : (
<SmallSpinner />
)}
</div>
</button>
/>
))}
</div>
{sortedTransfers?.length > 0 && (
@ -217,6 +143,79 @@ export function SideBarMenu({
);
}
function AccountSummary({ account, address }: { account: AccountInfo; address: Address }) {
const onClickCopy = async () => {
if (!address) return;
await tryClipboardSet(address);
toast.success('Address copied to clipboard', { autoClose: 2000 });
};
return (
<button
key={address}
onClick={onClickCopy}
className={`${styles.btn} border border-gray-200 rounded-xl`}
>
<div className="shrink-0">
<Identicon address={address} size={40} />
</div>
<div className="flex flex-col mx-3 items-start">
<div className="text-gray-800 text-sm font-normal">{account.connectorName || 'Wallet'}</div>
<div className="text-xs text-left truncate w-64">{address ? address : 'Unknown'}</div>
</div>
</button>
);
}
function TransferSummary({
transfer,
onClick,
}: {
transfer: TransferContext;
onClick: () => void;
}) {
const { amount, origin, destination, status, timestamp, originTokenAddressOrDenom } = transfer;
const token = getWarpCore().findToken(origin, originTokenAddressOrDenom);
return (
<button
key={timestamp}
onClick={onClick}
className="flex justify-between items-center rounded-xl border border-gray-200 px-2.5 py-2 mb-2.5 hover:bg-gray-200 active:bg-gray-300 transition-all duration-500"
>
<div className="flex">
<div className="mr-2.5 flex flex-col items-center justify-center rounded-full bg-gray-100 h-[2.25rem] w-[2.25rem] p-1.5">
<ChainLogo chainName={origin} size={20} />
</div>
<div className="flex flex-col">
<div className="flex flex-col">
<div className="flex items items-baseline">
<span className="text-gray-800 text-sm font-normal">{amount}</span>
<span className="text-gray-800 text-sm font-normal ml-1">{token?.symbol || ''}</span>
</div>
<div className="mt-1 flex flex-row items-center">
<span className="text-thin text-gray-900 font-normal tracking-wide">
{getChainDisplayName(origin, true)}
</span>
<Image className="mx-1" src={ArrowRightIcon} width={10} height={10} alt="" />
<span className="text-thin text-gray-900 font-normal tracking-wide">
{getChainDisplayName(destination, true)}
</span>
</div>
</div>
</div>
</div>
<div className="flex w-6 h-6">
{STATUSES_WITH_ICON.includes(status) ? (
<Image src={getIconByTransferStatus(status)} width={25} height={25} alt="" />
) : (
<SmallSpinner className="-ml-1 mr-3" />
)}
</div>
</button>
);
}
function Icon({
src,
alt,

@ -19,10 +19,10 @@ import { ProtocolType } from '@hyperlane-xyz/utils';
import { APP_NAME } from '../../../consts/app';
import { config } from '../../../consts/config';
import { tokenList } from '../../../consts/tokens';
import { getWarpCore } from '../../../context/context';
import { Color } from '../../../styles/Color';
import { getWagmiChainConfig } from '../../chains/metadata';
import { getMultiProvider } from '../../multiProvider';
import { tryGetChainMetadata } from '../../chains/utils';
const { chains, publicClient } = configureChains(getWagmiChainConfig(), [publicProvider()]);
@ -63,11 +63,9 @@ const wagmiConfig = createConfig({
export function EvmWalletContext({ children }: PropsWithChildren<unknown>) {
const initialChain = useMemo(() => {
const multiProvider = getMultiProvider();
return tokenList.filter(
(token) =>
multiProvider.tryGetChainMetadata(token.chainId)?.protocol === ProtocolType.Ethereum,
)?.[0]?.chainId as number;
const tokens = getWarpCore().tokens;
const firstEvmToken = tokens.filter((token) => token.protocol === ProtocolType.Ethereum)?.[0];
return tryGetChainMetadata(firstEvmToken?.chainName)?.chainId as number;
}, []);
return (
<WagmiConfig config={wagmiConfig}>

@ -3,35 +3,36 @@ import { useChain, useChains } from '@cosmos-kit/react';
import { useCallback, useMemo } from 'react';
import { toast } from 'react-toastify';
import { ProtocolType } from '@hyperlane-xyz/utils';
import { ProviderType } from '@hyperlane-xyz/sdk';
import { HexString, ProtocolType } from '@hyperlane-xyz/utils';
import { PLACEHOLDER_COSMOS_CHAIN } from '../../../consts/values';
import { logger } from '../../../utils/logger';
import { getCaip2Id } from '../../caip/chains';
import { getCosmosChainNames } from '../../chains/metadata';
import { getChainMetadata, getMultiProvider } from '../../multiProvider';
import { getChainMetadata } from '../../chains/utils';
import { AccountInfo, ActiveChainInfo, ChainAddress, ChainTransactionFns } from './types';
export function useCosmosAccount(): AccountInfo {
const chainToContext = useChains(getCosmosChainNames());
return useMemo<AccountInfo>(() => {
const cosmAddresses: Array<ChainAddress> = [];
let cosmConnectorName: string | undefined = undefined;
let isCosmAccountReady = false;
const multiProvider = getMultiProvider();
const addresses: Array<ChainAddress> = [];
let publicKey: Promise<HexString> | undefined = undefined;
let connectorName: string | undefined = undefined;
let isReady = false;
for (const [chainName, context] of Object.entries(chainToContext)) {
if (!context.address) continue;
const caip2Id = getCaip2Id(ProtocolType.Cosmos, multiProvider.getChainId(chainName));
cosmAddresses.push({ address: context.address, chainCaip2Id: caip2Id });
isCosmAccountReady = true;
cosmConnectorName ||= context.wallet?.prettyName;
addresses.push({ address: context.address, chainName });
publicKey = context.getAccount().then((acc) => Buffer.from(acc.pubkey).toString('hex'));
isReady = true;
connectorName ||= context.wallet?.prettyName;
}
return {
protocol: ProtocolType.Cosmos,
addresses: cosmAddresses,
connectorName: cosmConnectorName,
isReady: isCosmAccountReady,
addresses,
publicKey,
connectorName,
isReady,
};
}, [chainToContext]);
}
@ -56,36 +57,37 @@ export function useCosmosActiveChain(): ActiveChainInfo {
export function useCosmosTransactionFns(): ChainTransactionFns {
const chainToContext = useChains(getCosmosChainNames());
const onSwitchNetwork = useCallback(async (chainCaip2Id: ChainCaip2Id) => {
const chainName = getChainMetadata(chainCaip2Id).displayName;
toast.warn(`Cosmos wallet must be connected to origin chain ${chainName}}`);
const onSwitchNetwork = useCallback(async (chainName: ChainName) => {
const displayName = getChainMetadata(chainName).displayName || chainName;
toast.warn(`Cosmos wallet must be connected to origin chain ${displayName}}`);
}, []);
const onSendTx = useCallback(
async ({
tx,
chainCaip2Id,
activeCap2Id,
chainName,
activeChainName,
providerType,
}: {
tx: { type: 'cosmwasm' | 'stargate'; request: any };
chainCaip2Id: ChainCaip2Id;
activeCap2Id?: ChainCaip2Id;
tx: any;
chainName: ChainName;
activeChainName?: ChainName;
providerType?: ProviderType;
}) => {
const chainName = getChainMetadata(chainCaip2Id).name;
const chainContext = chainToContext[chainName];
if (!chainContext?.address) throw new Error(`Cosmos wallet not connected for ${chainName}`);
if (activeCap2Id && activeCap2Id !== chainCaip2Id) await onSwitchNetwork(chainCaip2Id);
logger.debug(`Sending ${tx.type} tx on chain ${chainCaip2Id}`);
if (activeChainName && activeChainName !== chainName) await onSwitchNetwork(chainName);
logger.debug(`Sending tx on chain ${chainName}`);
const { getSigningCosmWasmClient, getSigningStargateClient } = chainContext;
let result: ExecuteResult | DeliverTxResponse;
if (tx.type === 'cosmwasm') {
if (providerType === ProviderType.CosmJsWasm) {
const client = await getSigningCosmWasmClient();
result = await client.executeMultiple(chainContext.address, [tx.request], 'auto');
} else if (tx.type === 'stargate') {
result = await client.executeMultiple(chainContext.address, [tx], 'auto');
} else if (providerType === ProviderType.CosmJs) {
const client = await getSigningStargateClient();
result = await client.signAndBroadcast(chainContext.address, [tx.request], 'auto');
result = await client.signAndBroadcast(chainContext.address, [tx], 'auto');
} else {
throw new Error('Invalid cosmos tx type');
throw new Error(`Invalid cosmos provider type ${providerType}`);
}
const confirm = async () => {

@ -1,17 +1,13 @@
import { useConnectModal } from '@rainbow-me/rainbowkit';
import {
SendTransactionArgs,
sendTransaction,
switchNetwork,
waitForTransaction,
} from '@wagmi/core';
import { sendTransaction, switchNetwork, waitForTransaction } from '@wagmi/core';
import { useCallback, useMemo } from 'react';
import { useAccount, useDisconnect, useNetwork } from 'wagmi';
import { ProtocolType, sleep } from '@hyperlane-xyz/utils';
import { logger } from '../../../utils/logger';
import { getCaip2Id, getEthereumChainId } from '../../caip/chains';
import { getChainMetadata, tryGetChainMetadata } from '../../chains/utils';
import { ethers5TxToWagmiTx } from '../utils';
import { AccountInfo, ActiveChainInfo, ChainTransactionFns } from './types';
@ -46,15 +42,15 @@ export function useEvmActiveChain(): ActiveChainInfo {
return useMemo<ActiveChainInfo>(
() => ({
chainDisplayName: chain?.name,
chainCaip2Id: chain ? getCaip2Id(ProtocolType.Ethereum, chain.id) : undefined,
chainName: chain ? tryGetChainMetadata(chain.id)?.name : undefined,
}),
[chain],
);
}
export function useEvmTransactionFns(): ChainTransactionFns {
const onSwitchNetwork = useCallback(async (chainCaip2Id: ChainCaip2Id) => {
const chainId = getEthereumChainId(chainCaip2Id);
const onSwitchNetwork = useCallback(async (chainName: ChainName) => {
const chainId = getChainMetadata(chainName).chainId as number;
await switchNetwork({ chainId });
// Some wallets seem to require a brief pause after switch
await sleep(2000);
@ -67,19 +63,20 @@ export function useEvmTransactionFns(): ChainTransactionFns {
const onSendTx = useCallback(
async ({
tx,
chainCaip2Id,
activeCap2Id,
chainName,
activeChainName,
}: {
tx: SendTransactionArgs;
chainCaip2Id: ChainCaip2Id;
activeCap2Id?: ChainCaip2Id;
tx: any;
chainName: ChainName;
activeChainName?: ChainName;
}) => {
if (activeCap2Id && activeCap2Id !== chainCaip2Id) await onSwitchNetwork(chainCaip2Id);
const chainId = getEthereumChainId(chainCaip2Id);
logger.debug(`Sending tx on chain ${chainCaip2Id}`);
if (activeChainName && activeChainName !== chainName) await onSwitchNetwork(chainName);
logger.debug(`Sending tx on chain ${chainName}`);
const chainId = getChainMetadata(chainName).chainId as number;
const wagmiTx = ethers5TxToWagmiTx(tx);
const { hash } = await sendTransaction({
chainId,
...tx,
...wagmiTx,
});
const confirm = () => waitForTransaction({ chainId, hash, confirmations: 1 });
return { hash, confirm };

@ -1,11 +1,11 @@
import { useMemo } from 'react';
import { toast } from 'react-toastify';
import { ProtocolType } from '@hyperlane-xyz/utils';
import { HexString, ProtocolType } from '@hyperlane-xyz/utils';
import { config } from '../../../consts/config';
import { logger } from '../../../utils/logger';
import { tryGetProtocolType } from '../../caip/chains';
import { getChainProtocol, tryGetChainProtocol } from '../../chains/utils';
import {
useCosmosAccount,
@ -67,31 +67,44 @@ export function useAccounts(): {
);
}
export function useAccountForChain(chainCaip2Id?: ChainCaip2Id): AccountInfo | undefined {
export function useAccountForChain(chainName?: ChainName): AccountInfo | undefined {
const { accounts } = useAccounts();
if (!chainCaip2Id) return undefined;
const protocol = tryGetProtocolType(chainCaip2Id);
if (!chainName) return undefined;
const protocol = tryGetChainProtocol(chainName);
if (!protocol) return undefined;
return accounts[protocol];
return accounts?.[protocol];
}
export function useAccountAddressForChain(chainCaip2Id?: ChainCaip2Id): Address | undefined {
return getAccountAddressForChain(chainCaip2Id, useAccountForChain(chainCaip2Id));
export function useAccountAddressForChain(chainName?: ChainName): Address | undefined {
return getAccountAddressForChain(chainName, useAccounts().accounts);
}
export function getAccountAddressForChain(
chainCaip2Id?: ChainCaip2Id,
account?: AccountInfo,
chainName?: ChainName,
accounts?: Record<ProtocolType, AccountInfo>,
): Address | undefined {
if (!chainCaip2Id || !account?.addresses.length) return undefined;
if (account.protocol === ProtocolType.Cosmos) {
return account.addresses.find((a) => a.chainCaip2Id === chainCaip2Id)?.address;
if (!chainName || !accounts) return undefined;
const protocol = getChainProtocol(chainName);
const account = accounts[protocol];
if (protocol === ProtocolType.Cosmos) {
return account?.addresses.find((a) => a.chainName === chainName)?.address;
} else {
// Use first because only cosmos has the notion of per-chain addresses
return account.addresses[0].address;
return account?.addresses[0]?.address;
}
}
export function getAccountAddressAndPubKey(
chainName?: ChainName,
accounts?: Record<ProtocolType, AccountInfo>,
): { address?: Address; publicKey?: Promise<HexString> } {
const address = getAccountAddressForChain(chainName, accounts);
if (!accounts || !chainName || !address) return {};
const protocol = getChainProtocol(chainName);
const publicKey = accounts[protocol]?.publicKey;
return { address, publicKey };
}
export function useConnectFns(): Record<ProtocolType, () => void> {
const onConnectEthereum = useEvmConnectFn();
const onConnectSolana = useSolConnectFn();

@ -6,10 +6,9 @@ import { toast } from 'react-toastify';
import { ProtocolType } from '@hyperlane-xyz/utils';
import { getMultiProvider } from '../../../context/context';
import { logger } from '../../../utils/logger';
import { getCaip2Id, getChainReference } from '../../caip/chains';
import { getChainByRpcEndpoint } from '../../chains/utils';
import { getChainMetadata, getMultiProvider } from '../../multiProvider';
import { AccountInfo, ActiveChainInfo, ChainTransactionFns } from './types';
@ -48,7 +47,7 @@ export function useSolActiveChain(): ActiveChainInfo {
if (!metadata) return {};
return {
chainDisplayName: metadata.displayName,
chainCaip2Id: getCaip2Id(ProtocolType.Sealevel, metadata.chainId),
chainName: metadata.name,
};
}, [connectionEndpoint]);
}
@ -56,30 +55,29 @@ export function useSolActiveChain(): ActiveChainInfo {
export function useSolTransactionFns(): ChainTransactionFns {
const { sendTransaction: sendSolTransaction } = useWallet();
const onSwitchNetwork = useCallback(async (chainCaip2Id: ChainCaip2Id) => {
const chainName = getChainMetadata(chainCaip2Id).displayName;
const onSwitchNetwork = useCallback(async (chainName: ChainName) => {
toast.warn(`Solana wallet must be connected to origin chain ${chainName}}`);
}, []);
const onSendTx = useCallback(
async ({
tx,
chainCaip2Id,
activeCap2Id,
chainName,
activeChainName,
}: {
tx: Transaction;
chainCaip2Id: ChainCaip2Id;
activeCap2Id?: ChainCaip2Id;
chainName: ChainName;
activeChainName?: ChainName;
}) => {
if (activeCap2Id && activeCap2Id !== chainCaip2Id) await onSwitchNetwork(chainCaip2Id);
const rpcUrl = getMultiProvider().getRpcUrl(getChainReference(chainCaip2Id));
if (activeChainName && activeChainName !== chainName) await onSwitchNetwork(chainName);
const rpcUrl = getMultiProvider().getRpcUrl(chainName);
const connection = new Connection(rpcUrl, 'confirmed');
const {
context: { slot: minContextSlot },
value: { blockhash, lastValidBlockHeight },
} = await connection.getLatestBlockhashAndContext();
logger.debug(`Sending tx on chain ${chainCaip2Id}`);
logger.debug(`Sending tx on chain ${chainName}`);
const signature = await sendSolTransaction(tx, connection, { minContextSlot });
const confirm = () =>

@ -1,8 +1,9 @@
import { ProtocolType } from '@hyperlane-xyz/utils';
import { ProviderType } from '@hyperlane-xyz/sdk';
import { HexString, ProtocolType } from '@hyperlane-xyz/utils';
export interface ChainAddress {
address: string;
chainCaip2Id?: ChainCaip2Id;
chainName?: ChainName;
}
export interface AccountInfo {
@ -10,22 +11,26 @@ export interface AccountInfo {
// This needs to be an array instead of a single address b.c.
// Cosmos wallets have different addresses per chain
addresses: Array<ChainAddress>;
// And another Cosmos exception, public keys are needed
// for tx simulation and gas estimation
publicKey?: Promise<HexString>;
connectorName?: string;
isReady: boolean;
}
export interface ActiveChainInfo {
chainDisplayName?: string;
chainCaip2Id?: ChainCaip2Id;
chainName?: ChainName;
}
export type SendTransactionFn<TxReq = any, TxResp = any> = (params: {
tx: TxReq;
chainCaip2Id: ChainCaip2Id;
activeCap2Id?: ChainCaip2Id;
chainName: ChainName;
activeChainName?: ChainName;
providerType?: ProviderType;
}) => Promise<{ hash: string; confirm: () => Promise<TxResp> }>;
export type SwitchNetworkFn = (chainCaip2Id: ChainCaip2Id) => Promise<void>;
export type SwitchNetworkFn = (chainName: ChainName) => Promise<void>;
export interface ChainTransactionFns {
sendTransaction: SendTransactionFn;

4
src/global.d.ts vendored

@ -1,7 +1,7 @@
declare type Address = string;
declare type ChainName = string;
declare type ChainId = number | string;
declare type DomainId = number;
declare type ChainCaip2Id = `${string}:${string}`; // e.g. ethereum:1 or sealevel:1399811149
declare type TokenCaip19Id = `${string}:${string}/${string}:${string}`; // e.g. ethereum:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f
declare module '*.yaml' {
const data: any;

@ -1,9 +0,0 @@
#!/bin/bash
# Create placeholder files or ts-node script will fail
echo "{}" > ./src/context/_chains.json
echo "[]" > ./src/context/_tokens.json
echo "{}" > ./src/context/_routes.json
# Run actual build script
yarn ts-node src/scripts/buildConfigs/index.ts

@ -1,46 +0,0 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { MultiProtocolProvider } from '@hyperlane-xyz/sdk';
import { type WarpContext, setWarpContext } from '../../context/context';
import { logger } from '../../utils/logger';
import { getProcessedChainConfigs } from './chains';
import { getRouteConfigs } from './routes';
import { getProcessedTokenConfigs } from './tokens';
const CHAINS_OUT_PATH = path.resolve(__dirname, '../../context/_chains.json');
const TOKENS_OUT_PATH = path.resolve(__dirname, '../../context/_tokens.json');
const ROUTES_OUT_PATH = path.resolve(__dirname, '../../context/_routes.json');
async function main() {
logger.info('Getting chains');
const chains = getProcessedChainConfigs();
const multiProvider = new MultiProtocolProvider<{ mailbox?: Address }>(chains);
logger.info('Getting tokens');
const tokens = await getProcessedTokenConfigs(multiProvider);
const context: WarpContext = {
chains,
tokens,
routes: {},
multiProvider,
};
setWarpContext(context);
logger.info('Getting routes');
const routes = await getRouteConfigs(context);
logger.info(`Writing chains to file ${CHAINS_OUT_PATH}`);
fs.writeFileSync(CHAINS_OUT_PATH, JSON.stringify(chains, null, 2), 'utf8');
logger.info(`Writing tokens to file ${TOKENS_OUT_PATH}`);
fs.writeFileSync(TOKENS_OUT_PATH, JSON.stringify(tokens, null, 2), 'utf8');
logger.info(`Writing routes to file ${ROUTES_OUT_PATH}`);
fs.writeFileSync(ROUTES_OUT_PATH, JSON.stringify(routes, null, 2), 'utf8');
}
main()
.then(() => logger.info('Done processing configs'))
.catch((error) => logger.warn('Error processing configs', error));

@ -1,261 +0,0 @@
import { TokenType } from '@hyperlane-xyz/sdk';
import { SOL_ZERO_ADDRESS } from '../../consts/values';
import { computeTokenRoutes } from './routes';
describe('computeTokenRoutes', () => {
it('Handles empty list', () => {
const routesMap = computeTokenRoutes([]);
expect(routesMap).toBeTruthy();
expect(Object.values(routesMap).length).toBe(0);
});
it('Handles basic 3-node route', () => {
const routesMap = computeTokenRoutes([
{
type: TokenType.collateral,
tokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
routerAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
name: 'Weth',
symbol: 'WETH',
decimals: 18,
hypTokens: [
{
decimals: 18,
chain: 'ethereum:11155111',
router: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
},
{
decimals: 18,
chain: 'ethereum:44787',
router: '0xEcbc0faAA269Cf649AC8950838664BB7B355BD6C',
},
],
},
]);
expect(routesMap).toEqual({
'ethereum:5': {
'ethereum:11155111': [
{
type: 'collateralToSynthetic',
baseTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
baseRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originCaip2Id: 'ethereum:5',
originRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originDecimals: 18,
destCaip2Id: 'ethereum:11155111',
destRouterAddress: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
destDecimals: 18,
},
],
'ethereum:44787': [
{
type: 'collateralToSynthetic',
baseTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
baseRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originCaip2Id: 'ethereum:5',
originRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originDecimals: 18,
destCaip2Id: 'ethereum:44787',
destRouterAddress: '0xEcbc0faAA269Cf649AC8950838664BB7B355BD6C',
destDecimals: 18,
},
],
},
'ethereum:11155111': {
'ethereum:5': [
{
type: 'syntheticToCollateral',
baseTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
baseRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originCaip2Id: 'ethereum:11155111',
originRouterAddress: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
originDecimals: 18,
destCaip2Id: 'ethereum:5',
destRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
destDecimals: 18,
},
],
'ethereum:44787': [
{
type: 'syntheticToSynthetic',
baseTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
baseRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originCaip2Id: 'ethereum:11155111',
originRouterAddress: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
originDecimals: 18,
destCaip2Id: 'ethereum:44787',
destRouterAddress: '0xEcbc0faAA269Cf649AC8950838664BB7B355BD6C',
destDecimals: 18,
},
],
},
'ethereum:44787': {
'ethereum:5': [
{
type: 'syntheticToCollateral',
baseTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
baseRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originCaip2Id: 'ethereum:44787',
originRouterAddress: '0xEcbc0faAA269Cf649AC8950838664BB7B355BD6C',
originDecimals: 18,
destCaip2Id: 'ethereum:5',
destRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
destDecimals: 18,
},
],
'ethereum:11155111': [
{
type: 'syntheticToSynthetic',
baseTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
baseRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originCaip2Id: 'ethereum:44787',
originRouterAddress: '0xEcbc0faAA269Cf649AC8950838664BB7B355BD6C',
originDecimals: 18,
destCaip2Id: 'ethereum:11155111',
destRouterAddress: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
destDecimals: 18,
},
],
},
});
});
it('Handles multi-collateral route', () => {
const routesMap = computeTokenRoutes([
{
type: TokenType.collateral,
tokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
routerAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
name: 'Weth',
symbol: 'WETH',
decimals: 18,
hypTokens: [
{
decimals: 18,
chain: 'ethereum:11155111',
router: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
},
{
decimals: 6,
chain: 'sealevel:1399811151',
router: 'PJH5QAbxAqrrnSXfH3GHR8icua8CDFZmo97z91xmpvx',
},
],
},
{
type: TokenType.native,
tokenCaip19Id: `sealevel:1399811151/native:${SOL_ZERO_ADDRESS}`,
routerAddress: 'PJH5QAbxAqrrnSXfH3GHR8icua8CDFZmo97z91xmpvx',
name: 'Zebec',
symbol: 'ZBC',
decimals: 6,
hypTokens: [
{
decimals: 18,
chain: 'ethereum:11155111',
router: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
},
{
decimals: 18,
chain: 'ethereum:5',
router: '0x145de8760021c4ac6676376691b78038d3DE9097',
},
],
},
]);
expect(routesMap).toEqual({
'ethereum:5': {
'ethereum:11155111': [
{
type: 'collateralToSynthetic',
baseTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
baseRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originCaip2Id: 'ethereum:5',
originRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originDecimals: 18,
destCaip2Id: 'ethereum:11155111',
destRouterAddress: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
destDecimals: 18,
},
],
'sealevel:1399811151': [
{
type: 'collateralToCollateral',
baseTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
baseRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originCaip2Id: 'ethereum:5',
originRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originDecimals: 18,
destCaip2Id: 'sealevel:1399811151',
destRouterAddress: 'PJH5QAbxAqrrnSXfH3GHR8icua8CDFZmo97z91xmpvx',
destDecimals: 6,
destTokenCaip19Id:
'sealevel:1399811151/native:00000000000000000000000000000000000000000000',
},
],
},
'ethereum:11155111': {
'ethereum:5': [
{
type: 'syntheticToCollateral',
baseTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
baseRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
originCaip2Id: 'ethereum:11155111',
originRouterAddress: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
originDecimals: 18,
destCaip2Id: 'ethereum:5',
destRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
destDecimals: 18,
},
],
'sealevel:1399811151': [
{
type: 'syntheticToCollateral',
baseTokenCaip19Id:
'sealevel:1399811151/native:00000000000000000000000000000000000000000000',
baseRouterAddress: 'PJH5QAbxAqrrnSXfH3GHR8icua8CDFZmo97z91xmpvx',
originCaip2Id: 'ethereum:11155111',
originRouterAddress: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
originDecimals: 18,
destCaip2Id: 'sealevel:1399811151',
destRouterAddress: 'PJH5QAbxAqrrnSXfH3GHR8icua8CDFZmo97z91xmpvx',
destDecimals: 6,
},
],
},
'sealevel:1399811151': {
'ethereum:5': [
{
type: 'collateralToCollateral',
baseTokenCaip19Id:
'sealevel:1399811151/native:00000000000000000000000000000000000000000000',
baseRouterAddress: 'PJH5QAbxAqrrnSXfH3GHR8icua8CDFZmo97z91xmpvx',
originCaip2Id: 'sealevel:1399811151',
originRouterAddress: 'PJH5QAbxAqrrnSXfH3GHR8icua8CDFZmo97z91xmpvx',
originDecimals: 6,
destCaip2Id: 'ethereum:5',
destRouterAddress: '0x145de8760021c4ac6676376691b78038d3DE9097',
destDecimals: 18,
destTokenCaip19Id: 'ethereum:5/erc20:0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
},
],
'ethereum:11155111': [
{
type: 'collateralToSynthetic',
baseTokenCaip19Id:
'sealevel:1399811151/native:00000000000000000000000000000000000000000000',
baseRouterAddress: 'PJH5QAbxAqrrnSXfH3GHR8icua8CDFZmo97z91xmpvx',
originCaip2Id: 'sealevel:1399811151',
originRouterAddress: 'PJH5QAbxAqrrnSXfH3GHR8icua8CDFZmo97z91xmpvx',
originDecimals: 6,
destCaip2Id: 'ethereum:11155111',
destRouterAddress: '0xDcbc0faAA269Cf649AC8950838664BB7B355BD6B',
destDecimals: 18,
},
],
},
});
});
});

@ -1,212 +0,0 @@
import { ProtocolType, bytesToProtocolAddress, deepCopy, eqAddress } from '@hyperlane-xyz/utils';
import { ibcRoutes } from '../../consts/ibcRoutes';
import { WarpContext } from '../../context/context';
import { getCaip2Id } from '../../features/caip/chains';
import { getChainIdFromToken, isNonFungibleToken } from '../../features/caip/tokens';
import { Route, RouteType, RoutesMap } from '../../features/routes/types';
import { AdapterFactory } from '../../features/tokens/AdapterFactory';
import { isIbcToken } from '../../features/tokens/metadata';
import { TokenMetadata, TokenMetadataWithHypTokens } from '../../features/tokens/types';
import { logger } from '../../utils/logger';
export async function getRouteConfigs(context: WarpContext): Promise<RoutesMap> {
logger.info('Searching for token routes');
const processedTokens: TokenMetadataWithHypTokens[] = [];
for (const token of context.tokens) {
// Skip querying of IBC tokens
if (isIbcToken(token)) continue;
const tokenWithHypTokens = await fetchRemoteHypTokens(context, token);
processedTokens.push(tokenWithHypTokens);
}
let routes = computeTokenRoutes(processedTokens);
if (ibcRoutes) {
logger.info('Found ibc route configs, adding to route map');
routes = mergeRoutes(routes, ibcRoutes);
}
logger.info('Done searching for token routes');
return routes;
}
export async function fetchRemoteHypTokens(
context: WarpContext,
baseToken: TokenMetadata,
): Promise<TokenMetadataWithHypTokens> {
const {
symbol: baseSymbol,
tokenCaip19Id: baseTokenCaip19Id,
routerAddress: baseRouter,
} = baseToken;
const isNft = isNonFungibleToken(baseTokenCaip19Id);
logger.info(`Fetching remote tokens for symbol ${baseSymbol} (${baseTokenCaip19Id})`);
const baseAdapter = AdapterFactory.HypCollateralAdapterFromAddress(baseTokenCaip19Id, baseRouter);
const remoteRouters = await baseAdapter.getAllRouters();
logger.info(`Router addresses found:`, remoteRouters.length);
const hypTokens = await Promise.all(
remoteRouters.map(async (router) => {
const destMetadata = context.multiProvider.getChainMetadata(router.domain);
const protocol = destMetadata.protocol || ProtocolType.Ethereum;
const chain = getCaip2Id(protocol, context.multiProvider.getChainId(router.domain));
const formattedAddress = bytesToProtocolAddress(router.address, protocol);
if (isNft) return { chain, router: formattedAddress, decimals: 0 };
const routerDecimals = await getRemoteRouterDecimals(
context,
formattedAddress,
chain,
baseTokenCaip19Id,
baseToken.decimals,
);
return { chain, router: formattedAddress, decimals: routerDecimals };
}),
);
return { ...baseToken, hypTokens };
}
async function getRemoteRouterDecimals(
context: WarpContext,
router: Address,
chain: ChainCaip2Id,
baseToken: TokenCaip19Id,
originDecimals: number,
) {
// Attempt to find the decimals from the token list
const routerMetadata = context.tokens.find((token) => eqAddress(router, token.routerAddress));
if (routerMetadata) return routerMetadata.decimals;
// Otherwise try to query the contract
try {
const remoteAdapter = AdapterFactory.HypSyntheticTokenAdapterFromAddress(
baseToken,
chain,
router,
);
const metadata = await remoteAdapter.getMetadata();
return metadata.decimals;
} catch (error) {
logger.warn(`Failed to get metadata for router ${router} on chain ${chain}`);
}
// Fallback to using origin router's decimals
logger.warn('Falling back to origin decimals', originDecimals);
return originDecimals;
}
// Process token list to populates routesCache with all possible token routes (e.g. router pairs)
export function computeTokenRoutes(tokens: TokenMetadataWithHypTokens[]): RoutesMap {
const tokenRoutes: RoutesMap = {};
// Instantiate map structure
const allChainIds = getChainsFromTokens(tokens);
for (const origin of allChainIds) {
tokenRoutes[origin] = {};
for (const dest of allChainIds) {
if (origin === dest) continue;
tokenRoutes[origin][dest] = [];
}
}
// Compute all possible routes, in both directions
for (const token of tokens) {
for (const remoteHypToken of token.hypTokens) {
const {
tokenCaip19Id: baseTokenCaip19Id,
routerAddress: baseRouterAddress,
decimals: baseDecimals,
} = token;
const baseChainCaip2Id = getChainIdFromToken(baseTokenCaip19Id);
const {
chain: remoteChainCaip2Id,
router: remoteRouterAddress,
decimals: remoteDecimals,
} = remoteHypToken;
// Check if the token list contains the dest router address, meaning it's also a base collateral token
const remoteBaseTokenConfig = findTokenByRouter(tokens, remoteRouterAddress);
const commonRouteProps = { baseTokenCaip19Id, baseRouterAddress };
// Register a route from the base to the remote
tokenRoutes[baseChainCaip2Id][remoteChainCaip2Id]?.push({
type: remoteBaseTokenConfig
? RouteType.CollateralToCollateral
: RouteType.CollateralToSynthetic,
...commonRouteProps,
originCaip2Id: baseChainCaip2Id,
originRouterAddress: baseRouterAddress,
originDecimals: baseDecimals,
destCaip2Id: remoteChainCaip2Id,
destRouterAddress: remoteRouterAddress,
destDecimals: remoteDecimals,
destTokenCaip19Id: remoteBaseTokenConfig ? remoteBaseTokenConfig.tokenCaip19Id : undefined,
});
// If the remote is not a synthetic (i.e. it's a native/collateral token with it's own config)
// then stop here to avoid duplicate route entries.
if (remoteBaseTokenConfig) continue;
// Register a route back from the synthetic remote to the base
tokenRoutes[remoteChainCaip2Id][baseChainCaip2Id]?.push({
type: RouteType.SyntheticToCollateral,
...commonRouteProps,
originCaip2Id: remoteChainCaip2Id,
originRouterAddress: remoteRouterAddress,
originDecimals: remoteDecimals,
destCaip2Id: baseChainCaip2Id,
destRouterAddress: baseRouterAddress,
destDecimals: baseDecimals,
});
// Now create routes from the remote synthetic token to all other hypTokens
// This assumes the synthetics were all enrolled to connect to each other
// which is the deployer's default behavior
for (const otherHypToken of token.hypTokens) {
const { chain: otherSynCaip2Id, router: otherHypTokenAddress } = otherHypToken;
// Skip if it's same hypToken as parent loop (no route to self)
if (otherHypToken === remoteHypToken) continue;
// Also skip if remote isn't a synthetic (i.e. has a collateral/native config)
if (findTokenByRouter(tokens, otherHypTokenAddress)) continue;
tokenRoutes[remoteChainCaip2Id][otherSynCaip2Id]?.push({
type: RouteType.SyntheticToSynthetic,
...commonRouteProps,
originCaip2Id: remoteChainCaip2Id,
originRouterAddress: remoteRouterAddress,
originDecimals: remoteDecimals,
destCaip2Id: otherSynCaip2Id,
destRouterAddress: otherHypTokenAddress,
destDecimals: otherHypToken.decimals,
});
}
}
}
return tokenRoutes;
}
function getChainsFromTokens(tokens: TokenMetadataWithHypTokens[]): ChainCaip2Id[] {
const chains = new Set<ChainCaip2Id>();
for (const token of tokens) {
chains.add(getChainIdFromToken(token.tokenCaip19Id));
for (const hypToken of token.hypTokens) {
chains.add(hypToken.chain);
}
}
return Array.from(chains);
}
function findTokenByRouter(tokens: TokenMetadataWithHypTokens[], router: Address) {
return tokens.find((t) => eqAddress(t.routerAddress, router));
}
export function mergeRoutes(routes: RoutesMap, newRoutes: Route[]) {
const mergedRoutes = deepCopy(routes);
for (const route of newRoutes) {
mergedRoutes[route.originCaip2Id] ||= {};
mergedRoutes[route.originCaip2Id][route.destCaip2Id] ||= [];
mergedRoutes[route.originCaip2Id][route.destCaip2Id].push(route);
}
return mergedRoutes;
}

@ -1,132 +0,0 @@
import path from 'path';
import {
EvmTokenAdapter,
ITokenAdapter,
MultiProtocolProvider,
TokenType,
} from '@hyperlane-xyz/sdk';
import { ProtocolType } from '@hyperlane-xyz/utils';
import TokensJson from '../../consts/tokens.json';
import { tokenList as TokensTS } from '../../consts/tokens.ts';
import { getCaip2Id } from '../../features/caip/chains';
import {
getCaip19Id,
getNativeTokenAddress,
resolveAssetNamespace,
} from '../../features/caip/tokens';
import { getHypErc20CollateralContract } from '../../features/tokens/contracts/evmContracts';
import {
MinimalTokenMetadata,
TokenMetadata,
WarpTokenConfig,
WarpTokenConfigSchema,
} from '../../features/tokens/types';
import { logger } from '../../utils/logger';
import { readYaml } from './utils';
export async function getProcessedTokenConfigs(multiProvider: MultiProtocolProvider) {
const TokensYaml = readYaml(path.resolve(__dirname, '../../consts/tokens.yaml'));
const tokenList = [...TokensJson, ...TokensYaml, ...TokensTS];
const tokens = await parseTokenConfigs(multiProvider, tokenList);
return tokens;
}
// Converts the more user-friendly config format into a validated, extended format
// that's easier for the UI to work with
async function parseTokenConfigs(
multiProvider: MultiProtocolProvider,
configList: WarpTokenConfig,
): Promise<TokenMetadata[]> {
const result = WarpTokenConfigSchema.safeParse(configList);
if (!result.success) {
logger.warn('Invalid token config', result.error);
throw new Error(`Invalid token config: ${result.error.toString()}`);
}
const parsedConfig = result.data;
const tokenMetadata: TokenMetadata[] = [];
for (const config of parsedConfig) {
const { type, chainId, logoURI, igpTokenAddressOrDenom } = config;
const protocol = multiProvider.getChainMetadata(chainId).protocol || ProtocolType.Ethereum;
const chainCaip2Id = getCaip2Id(protocol, chainId);
const isNative = type == TokenType.native;
const isNft = type === TokenType.collateral && config.isNft;
const isSpl2022 = type === TokenType.collateral && config.isSpl2022;
const address =
type === TokenType.collateral ? config.address : getNativeTokenAddress(protocol);
const routerAddress =
type === TokenType.collateral
? config.hypCollateralAddress
: type === TokenType.native
? config.hypNativeAddress
: '';
const namespace = resolveAssetNamespace(protocol, isNative, isNft, isSpl2022);
const tokenCaip19Id = getCaip19Id(chainCaip2Id, namespace, address);
const { name, symbol, decimals } = await fetchNameAndDecimals(
multiProvider,
config,
protocol,
routerAddress,
isNft,
);
tokenMetadata.push({
name,
symbol,
decimals,
logoURI,
type,
tokenCaip19Id,
routerAddress,
igpTokenAddressOrDenom,
});
}
return tokenMetadata;
}
async function fetchNameAndDecimals(
multiProvider: MultiProtocolProvider,
tokenConfig: WarpTokenConfig[number],
protocol: ProtocolType,
routerAddress: Address,
isNft?: boolean,
): Promise<MinimalTokenMetadata> {
const { type, chainId, name, symbol, decimals } = tokenConfig;
if (name && symbol && decimals) {
// Already provided in the config
return { name, symbol, decimals };
}
const chainMetadata = multiProvider.getChainMetadata(chainId);
if (type === TokenType.native) {
// Use the native token config that may be in the chain metadata
const tokenMetadata = chainMetadata.nativeToken;
if (!tokenMetadata) throw new Error('Name, symbol, or decimals is missing for native token');
return tokenMetadata;
}
if (type === TokenType.collateral) {
// Fetch the data from the contract
let tokenAdapter: ITokenAdapter;
if (protocol === ProtocolType.Ethereum) {
const provider = multiProvider.getEthersV5Provider(chainId);
const collateralContract = getHypErc20CollateralContract(routerAddress, provider);
const wrappedTokenAddr = await collateralContract.wrappedToken();
tokenAdapter = new EvmTokenAdapter(chainMetadata.name, multiProvider, {
token: wrappedTokenAddr,
});
} else {
// TODO solana support when hyp tokens have metadata
throw new Error('Name, symbol, and decimals is required for non-EVM token configs');
}
return tokenAdapter.getMetadata(isNft);
}
throw new Error(`Unsupported token type ${type}`);
}

@ -1,6 +0,0 @@
import fs from 'fs';
import { parse } from 'yaml';
export function readYaml(path: string) {
return parse(fs.readFileSync(path, 'utf-8'));
}

@ -2,17 +2,14 @@ import { toBase64 } from '@hyperlane-xyz/utils';
import { config } from '../consts/config';
import { links } from '../consts/links';
import { parseCaip2Id } from '../features/caip/chains';
import { isPermissionlessChain } from '../features/chains/utils';
import { getMultiProvider } from '../features/multiProvider';
import { getChainMetadata, isPermissionlessChain } from '../features/chains/utils';
// TODO test with cosmos chain config, or disallow it
export function getHypExplorerLink(originCaip2Id: ChainCaip2Id, msgId?: string) {
if (!config.enableExplorerLink || !originCaip2Id || !msgId) return null;
export function getHypExplorerLink(chain: ChainName, msgId?: string) {
if (!config.enableExplorerLink || !chain || !msgId) return null;
const baseLink = `${links.explorer}/message/${msgId}`;
if (isPermissionlessChain(originCaip2Id)) {
const { reference } = parseCaip2Id(originCaip2Id);
const chainConfig = getMultiProvider().getChainMetadata(reference);
if (isPermissionlessChain(chain)) {
const chainConfig = getChainMetadata(chain);
const serializedConfig = toBase64([chainConfig]);
if (serializedConfig) {
const params = new URLSearchParams({ chains: serializedConfig });

@ -18,14 +18,11 @@ export function getTransferStatusLabel(
statusDescription = 'Please connect wallet to continue';
else if (status === TransferStatus.Preparing)
statusDescription = 'Preparing for token transfer...';
else if (status === TransferStatus.CreatingApprove)
statusDescription = 'Preparing approve transaction...';
else if (status === TransferStatus.CreatingTxs) statusDescription = 'Creating transactions...';
else if (status === TransferStatus.SigningApprove)
statusDescription = `Sign approve transaction in ${connectorName} to continue.`;
else if (status === TransferStatus.ConfirmingApprove)
statusDescription = 'Confirming approve transaction...';
else if (status === TransferStatus.CreatingTransfer)
statusDescription = 'Preparing transfer transaction...';
else if (status === TransferStatus.SigningTransfer)
statusDescription = `Sign transfer transaction in ${connectorName} to continue.`;
else if (status === TransferStatus.ConfirmingTransfer)

@ -0,0 +1,15 @@
import { SafeParseReturnType } from 'zod';
import { logger } from './logger';
export function validateZodResult<T>(
result: SafeParseReturnType<T, T>,
desc: string = 'config',
): T {
if (!result.success) {
logger.warn(`Invalid ${desc}`, result.error);
throw new Error(`Invalid desc: ${result.error.toString()}`);
} else {
return result.data;
}
}

@ -2995,30 +2995,30 @@ __metadata:
languageName: node
linkType: hard
"@hyperlane-xyz/core@npm:3.6.1":
version: 3.6.1
resolution: "@hyperlane-xyz/core@npm:3.6.1"
"@hyperlane-xyz/core@npm:3.8.0":
version: 3.8.0
resolution: "@hyperlane-xyz/core@npm:3.8.0"
dependencies:
"@eth-optimism/contracts": "npm:^0.6.0"
"@hyperlane-xyz/utils": "npm:3.6.1"
"@hyperlane-xyz/utils": "npm:3.8.0"
"@openzeppelin/contracts": "npm:^4.9.3"
"@openzeppelin/contracts-upgradeable": "npm:^v4.9.3"
peerDependencies:
"@ethersproject/abi": "*"
"@ethersproject/providers": "*"
"@types/sinon-chai": "*"
checksum: 15cf69663a6f80ef8c656f283a2af84c9be58cb2a84f88820cda5b6233157aaac69557b97983ad4138078b0dc0a77cb37f6bc992d4ba8bf4b5e512cbf942526f
checksum: f0f614bd1a1d8a755d8522409473b5cb3042304450e3ffb8ac96cd2756ca27b9a6f0a243608ffddf70a31af3b1e8dba0138154615c41424b2fb2e5baca52c963
languageName: node
linkType: hard
"@hyperlane-xyz/sdk@npm:^3.6.1":
version: 3.6.1
resolution: "@hyperlane-xyz/sdk@npm:3.6.1"
"@hyperlane-xyz/sdk@npm:^3.8.0":
version: 3.8.0
resolution: "@hyperlane-xyz/sdk@npm:3.8.0"
dependencies:
"@cosmjs/cosmwasm-stargate": "npm:^0.31.3"
"@cosmjs/stargate": "npm:^0.31.3"
"@hyperlane-xyz/core": "npm:3.6.1"
"@hyperlane-xyz/utils": "npm:3.6.1"
"@hyperlane-xyz/core": "npm:3.8.0"
"@hyperlane-xyz/utils": "npm:3.8.0"
"@solana/spl-token": "npm:^0.3.8"
"@solana/web3.js": "npm:^1.78.0"
"@types/coingecko-api": "npm:^1.0.10"
@ -3035,19 +3035,19 @@ __metadata:
peerDependencies:
"@ethersproject/abi": "*"
"@ethersproject/providers": "*"
checksum: 9c5b0cd9c44ff8f8193740cd895a87c84990d9466ffd695a35f1a0f71dafb8dee2ac294295df3be2849f520ef4ec236cc641f98eb93369a67d2727a75249c7cb
checksum: 5ca551b639a3a5a92266adbac9da973dd417e8a399797c2449b07af15c0f1f4659d1b98f4c1b834db999db476f66b832db4eac37efa1b9f50bc6c2530b2f98fd
languageName: node
linkType: hard
"@hyperlane-xyz/utils@npm:3.6.1, @hyperlane-xyz/utils@npm:^3.6.1":
version: 3.6.1
resolution: "@hyperlane-xyz/utils@npm:3.6.1"
"@hyperlane-xyz/utils@npm:3.8.0, @hyperlane-xyz/utils@npm:^3.8.0":
version: 3.8.0
resolution: "@hyperlane-xyz/utils@npm:3.8.0"
dependencies:
"@cosmjs/encoding": "npm:^0.31.3"
"@solana/web3.js": "npm:^1.78.0"
bignumber.js: "npm:^9.1.1"
ethers: "npm:^5.7.2"
checksum: dca6656ba047fac0ab8e8e9cc4687cdc1784d2245efa57bf4826f35eac6fa0f2c6fb9834f1dc3b699ce3cb0b5ccfd02940d7eb3b574204d8c2b4f4778aa6d301
checksum: 9d313133d3cc0cdae605c96ffdcebb704a5951a75201bc23be8a2653fbad45e0a1fd4782f8d93ec0b5a01bddaa98999ef5f320cfbb6eef44e0de2176a8a4fb7b
languageName: node
linkType: hard
@ -3067,9 +3067,9 @@ __metadata:
"@emotion/react": "npm:^11.11.1"
"@emotion/styled": "npm:^11.11.0"
"@headlessui/react": "npm:^1.7.14"
"@hyperlane-xyz/sdk": "npm:^3.6.1"
"@hyperlane-xyz/utils": "npm:^3.6.1"
"@hyperlane-xyz/widgets": "npm:^3.1.4"
"@hyperlane-xyz/sdk": "npm:^3.8.0"
"@hyperlane-xyz/utils": "npm:^3.8.0"
"@hyperlane-xyz/widgets": "npm:^3.8.0"
"@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6"
"@next/bundle-analyzer": "npm:^14.0.4"
"@rainbow-me/rainbowkit": "npm:1.3.0"
@ -3118,14 +3118,14 @@ __metadata:
languageName: unknown
linkType: soft
"@hyperlane-xyz/widgets@npm:^3.1.4":
version: 3.1.4
resolution: "@hyperlane-xyz/widgets@npm:3.1.4"
"@hyperlane-xyz/widgets@npm:^3.8.0":
version: 3.8.0
resolution: "@hyperlane-xyz/widgets@npm:3.8.0"
peerDependencies:
"@hyperlane-xyz/sdk": ^3.1
react: ^18
react-dom: ^18
checksum: 0183bdb11015d07bdd92be033e658dbcd614cece361654a00d63e3fe8c81c962d9f7fa0e9e51ab0affc59b1c0c94da7c433794ae0e7ca4a0e6d77f42f4a0744c
checksum: 2a36a90d43250c86084b05580909f316f13ed37a9416feea4e20411cd16ca42d430c5746636abfc9eb948199f888cf2f02b1e4d2a92dbc7fa4eee24af0f13579
languageName: node
linkType: hard
@ -14037,20 +14037,13 @@ __metadata:
languageName: node
linkType: hard
"react-fast-compare@npm:3.2.2":
"react-fast-compare@npm:^3.2":
version: 3.2.2
resolution: "react-fast-compare@npm:3.2.2"
checksum: a6826180ba75cefba1c8d3ac539735f9b627ca05d3d307fe155487f5d0228d376dac6c9708d04a283a7b9f9aee599b637446635b79c8c8753d0b4eece56c125c
languageName: node
linkType: hard
"react-fast-compare@npm:^2.0.1":
version: 2.0.4
resolution: "react-fast-compare@npm:2.0.4"
checksum: e4e3218c0f5c29b88e9f184a12adb77b0a93a803dbd45cb98bbb754c8310dc74e6266c53dd70b90ba4d0939e0e1b8a182cb05d081bcab22507a0390fbcd768ac
languageName: node
linkType: hard
"react-focus-lock@npm:^2.9.4":
version: 2.9.6
resolution: "react-focus-lock@npm:2.9.6"

Loading…
Cancel
Save