diff --git a/src/components/toast/IgpDetailsToast.tsx b/src/components/toast/IgpDetailsToast.tsx new file mode 100644 index 0000000..846b9a4 --- /dev/null +++ b/src/components/toast/IgpDetailsToast.tsx @@ -0,0 +1,21 @@ +import { toast } from 'react-toastify'; + +import { links } from '../../consts/links'; + +export function toastIgpDetails() { + toast.error(, { + autoClose: 5000, + }); +} + +export function IgpDetailsToast() { + return ( +
+ Cross-chain transfers require a small amount of extra gas to fund delivery. Your native token + balance is insufficient.{' '} + + Learn More + +
+ ); +} diff --git a/src/consts/links.ts b/src/consts/links.ts index b2f42b1..cd25d67 100644 --- a/src/consts/links.ts +++ b/src/consts/links.ts @@ -4,6 +4,7 @@ export const links = { discord: 'https://discord.gg/VK9ZUy3aTV', github: 'https://github.com/hyperlane-xyz/hyperlane-warp-ui-template', docs: 'https://docs.hyperlane.xyz', + gasDocs: 'https://docs.hyperlane.xyz/docs/protocol/interchain-gas-payments', chains: 'https://docs.hyperlane.xyz/docs/resources/domains', twitter: 'https://twitter.com/hyperlane_xyz', blog: 'https://medium.com/hyperlane', diff --git a/src/features/multiProvider.ts b/src/features/multiProvider.ts index 5b57792..2e7079c 100644 --- a/src/features/multiProvider.ts +++ b/src/features/multiProvider.ts @@ -26,3 +26,7 @@ export function getSealevelProvider(id: ChainCaip2Id) { if (protocol !== ProtocolType.Sealevel) throw new Error('Expected Sealevel chain for provider'); return getMultiProvider().getSolanaWeb3Provider(reference); } + +export function getChainMetadata(id: ChainCaip2Id) { + return getMultiProvider().getChainMetadata(parseCaip2Id(id).reference); +} diff --git a/src/features/store.ts b/src/features/store.ts index dce9c5b..94c8c4c 100644 --- a/src/features/store.ts +++ b/src/features/store.ts @@ -17,17 +17,30 @@ export interface AppState { s: TransferStatus, options?: { msgId?: string; originTxHash?: string }, ) => void; + failUnconfirmedTransfers: () => void; + transferLoading: boolean; + setTransferLoading: (isLoading: boolean) => void; balances: { - senderBalance: string; + senderTokenBalance: string; + senderNativeBalance: string; senderNftIds: string[] | null; // null means unknown isSenderNftOwner: boolean | null; }; - failUnconfirmedTransfers: () => void; - setSenderBalance: (b: string) => void; + setSenderBalances: (tokenBalance: string, nativeBalance: string) => void; setSenderNftIds: (ids: string[] | null) => void; setIsSenderNftOwner: (isOwner: boolean | null) => void; - transferLoading: boolean; - setTransferLoading: (isLoading: boolean) => void; + igpQuote: { + weiAmount: string; + originCaip2Id: ChainCaip2Id; + destinationCaip2Id: ChainCaip2Id; + } | null; + setIgpQuote: ( + quote: { + weiAmount: string; + originCaip2Id: ChainCaip2Id; + destinationCaip2Id: ChainCaip2Id; + } | null, + ) => void; } export const useStore = create()( @@ -64,12 +77,15 @@ export const useStore = create()( set(() => ({ transferLoading: isLoading })); }, balances: { - senderBalance: '0', + senderTokenBalance: '0', + senderNativeBalance: '0', senderNftIds: null, isSenderNftOwner: false, }, - setSenderBalance: (senderBalance) => { - set((state) => ({ balances: { ...state.balances, senderBalance } })); + setSenderBalances: (senderTokenBalance, senderNativeBalance) => { + set((state) => ({ + balances: { ...state.balances, senderTokenBalance, senderNativeBalance }, + })); }, setSenderNftIds: (senderNftIds) => { set((state) => ({ balances: { ...state.balances, senderNftIds } })); @@ -77,6 +93,10 @@ export const useStore = create()( setIsSenderNftOwner: (isSenderNftOwner) => { set((state) => ({ balances: { ...state.balances, isSenderNftOwner } })); }, + igpQuote: null, + setIgpQuote: (quote) => { + set(() => ({ igpQuote: quote })); + }, }), { name: 'app-state', diff --git a/src/features/tokens/AdapterFactory.ts b/src/features/tokens/AdapterFactory.ts index 886eb94..de6028a 100644 --- a/src/features/tokens/AdapterFactory.ts +++ b/src/features/tokens/AdapterFactory.ts @@ -26,6 +26,19 @@ import { } from './routes/utils'; export class AdapterFactory { + static NativeAdapterFromChain(chainCaip2Id: ChainCaip2Id) { + 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 { + throw new Error(`Unsupported protocol: ${protocol}`); + } + } + static TokenAdapterFromAddress(tokenCaip19Id: TokenCaip19Id) { const { address, chainCaip2Id } = parseCaip19Id(tokenCaip19Id); const { protocol, reference: chainId } = parseCaip2Id(chainCaip2Id); diff --git a/src/features/tokens/approval.ts b/src/features/tokens/approval.ts index 37819ed..77605b0 100644 --- a/src/features/tokens/approval.ts +++ b/src/features/tokens/approval.ts @@ -13,9 +13,9 @@ import { Route } from './routes/types'; import { isRouteFromCollateral } from './routes/utils'; export function useIsApproveRequired( - route: Route, tokenCaip19Id: TokenCaip19Id, amount: string, + route?: Route, enabled = true, ) { const owner = useAccountForChain(route?.originCaip2Id)?.address; diff --git a/src/features/tokens/balances.ts b/src/features/tokens/balances.ts index 0d46ce4..adb660a 100644 --- a/src/features/tokens/balances.ts +++ b/src/features/tokens/balances.ts @@ -14,14 +14,14 @@ import { useAccountForChain } from '../wallet/hooks'; import { AdapterFactory } from './AdapterFactory'; import { getHypErc721Contract } from './contracts/evmContracts'; import { RoutesMap } from './routes/types'; -import { getTokenRoute } from './routes/utils'; +import { getTokenRoute, isRouteFromNative } from './routes/utils'; export function useOriginBalance( { originCaip2Id, destinationCaip2Id, tokenCaip19Id }: TransferFormValues, tokenRoutes: RoutesMap, ) { const address = useAccountForChain(originCaip2Id)?.address; - const setSenderBalance = useStore((state) => state.setSenderBalance); + const setSenderBalances = useStore((state) => state.setSenderBalances); const { isLoading, @@ -40,18 +40,33 @@ export function useOriginBalance( const route = getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes); const protocol = getProtocolType(originCaip2Id); if (!route || !address || !isValidAddress(address, protocol)) return null; - const adapter = AdapterFactory.HypTokenAdapterFromRouteOrigin(route); - const balance = await adapter.getBalance(address); - return { balance, decimals: route.originDecimals }; + const tokenAdapter = AdapterFactory.HypTokenAdapterFromRouteOrigin(route); + const tokenBalance = await tokenAdapter.getBalance(address); + + let nativeBalance; + if (isRouteFromNative(route)) { + nativeBalance = tokenBalance; + } else { + const nativeAdapter = AdapterFactory.NativeAdapterFromChain(originCaip2Id); + nativeBalance = await nativeAdapter.getBalance(address); + } + + return { tokenBalance, tokenDecimals: route.originDecimals, nativeBalance }; }, refetchInterval: 5000, }); useEffect(() => { - setSenderBalance(data?.balance || '0'); - }, [data?.balance, setSenderBalance]); + setSenderBalances(data?.tokenBalance || '0', data?.nativeBalance || '0'); + }, [data, setSenderBalances]); - return { isLoading, hasError, balance: data?.balance, decimals: data?.decimals }; + return { + isLoading, + hasError, + tokenBalance: data?.tokenBalance, + tokenDecimals: data?.tokenDecimals, + nativeBalance: data?.nativeBalance, + }; } export function useDestinationBalance( diff --git a/src/features/tokens/routes/utils.ts b/src/features/tokens/routes/utils.ts index 81e6774..8bfa5b4 100644 --- a/src/features/tokens/routes/utils.ts +++ b/src/features/tokens/routes/utils.ts @@ -1,3 +1,5 @@ +import { isNativeToken } from '../../caip/tokens'; + import { Route, RouteType, RoutesMap } from './types'; export function getTokenRoutes( @@ -54,3 +56,7 @@ export function isRouteFromSynthetic(route: Route) { route.type === RouteType.SyntheticToCollateral || route.type === RouteType.SyntheticToSynthetic ); } + +export function isRouteFromNative(route: Route) { + return isRouteFromCollateral(route) && isNativeToken(route.baseTokenCaip19Id); +} diff --git a/src/features/transfer/TransferTokenForm.tsx b/src/features/transfer/TransferTokenForm.tsx index 81be79e..96ef5b4 100644 --- a/src/features/transfer/TransferTokenForm.tsx +++ b/src/features/transfer/TransferTokenForm.tsx @@ -19,6 +19,7 @@ import { IconButton } from '../../components/buttons/IconButton'; import { SolidButton } from '../../components/buttons/SolidButton'; import { ChevronIcon } from '../../components/icons/Chevron'; import { TextField } from '../../components/input/TextField'; +import { toastIgpDetails } from '../../components/toast/IgpDetailsToast'; import { config } from '../../consts/config'; import SwapIcon from '../../images/icons/swap.svg'; import { Color } from '../../styles/Color'; @@ -27,17 +28,20 @@ import { getProtocolType } from '../caip/chains'; import { getTokenAddress, isNonFungibleToken, parseCaip19Id } from '../caip/tokens'; import { ChainSelectField } from '../chains/ChainSelectField'; import { getChainDisplayName } from '../chains/utils'; +import { getChainMetadata } from '../multiProvider'; import { AppState, 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 { useRouteChains } from '../tokens/routes/hooks'; import { RoutesMap } from '../tokens/routes/types'; -import { getTokenRoute } from '../tokens/routes/utils'; +import { getTokenRoute, isRouteFromNative } from '../tokens/routes/utils'; import { useAccountForChain } from '../wallet/hooks'; import { TransferFormValues } from './types'; +import { useIgpQuote } from './useIgpQuote'; import { useTokenTransfer } from './useTokenTransfer'; export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) { @@ -49,10 +53,13 @@ export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) { // Flag for check current type of token const [isNft, setIsNft] = useState(false); - const balances = useStore((state) => state.balances); + const { balances, igpQuote } = useStore((state) => ({ + balances: state.balances, + igpQuote: state.igpQuote, + })); const validate = (values: TransferFormValues) => - validateFormValues(values, tokenRoutes, balances); + validateFormValues(values, tokenRoutes, balances, igpQuote); const onSubmitForm = (values: TransferFormValues) => { logger.debug('Reviewing transfer form values:', JSON.stringify(values)); @@ -190,7 +197,7 @@ function AmountSection({ isReview: boolean; }) { const { values } = useFormikContext(); - const { balance, decimals } = useOriginBalance(values, tokenRoutes); + const { tokenBalance, tokenDecimals } = useOriginBalance(values, tokenRoutes); return (
@@ -198,7 +205,7 @@ function AmountSection({ - +
{isNft ? ( @@ -212,7 +219,7 @@ function AmountSection({ step="any" disabled={isReview} /> - + )} @@ -395,15 +402,25 @@ function SelfButton({ disabled }: { disabled?: boolean }) { function ReviewDetails({ visible, tokenRoutes }: { visible: boolean; tokenRoutes: RoutesMap }) { const { - values: { amount, originCaip2Id, destinationCaip2Id, tokenCaip19Id: token }, + values: { amount, originCaip2Id, destinationCaip2Id, tokenCaip19Id }, } = useFormikContext(); - const route = getTokenRoute(originCaip2Id, destinationCaip2Id, token, tokenRoutes); - const isNft = token && isNonFungibleToken(token); - const sendValue = isNft ? amount.toString() : toWei(amount, route?.originDecimals).toString(); - const { isLoading, isApproveRequired } = useIsApproveRequired(route!, token, sendValue, visible); - const originProtocol = getProtocolType(originCaip2Id); - const originUnitName = ProtocolSmallestUnit[originProtocol]; + const route = getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes); + const isNft = tokenCaip19Id && isNonFungibleToken(tokenCaip19Id); + const sendValue = isNft ? amount.toString() : toWei(amount, route?.originDecimals).toFixed(0); + const originUnitName = ProtocolSmallestUnit[getProtocolType(originCaip2Id)]; + const originTokenSymbol = getToken(tokenCaip19Id)?.symbol || ''; + const originNativeTokenSymbol = getChainMetadata(originCaip2Id)?.nativeToken?.symbol || ''; + + const { isLoading: isApproveLoading, isApproveRequired } = useIsApproveRequired( + tokenCaip19Id, + sendValue, + route, + visible, + ); + const { isLoading: isQuoteLoading, igpQuote } = useIgpQuote(route); + + const isLoading = isApproveLoading || isQuoteLoading; return (

Transaction 1: Approve Transfer

-

{`Token Address: ${getTokenAddress(token)}`}

+

{`Token Address: ${getTokenAddress(tokenCaip19Id)}`}

{`Collateral Address: ${route?.baseRouterAddress}`}

@@ -430,11 +447,26 @@ function ReviewDetails({ visible, tokenRoutes }: { visible: boolean; tokenRoutes

{`Transaction${isApproveRequired ? ' 2' : ''}: Transfer Remote`}

-

{`Remote Token: ${route?.destRouterAddress}`}

+

+ Remote Token + {route?.destRouterAddress} +

{isNft ? ( -

{`Token ID: ${sendValue}`}

+

+ Token ID + {sendValue} +

) : ( -

{`Amount (${originUnitName}): ${sendValue}`}

+ <> +

+ {`Amount (${originUnitName})`} + {`${sendValue} ${originTokenSymbol}`} +

+

+ {`Interchain Gas (${originUnitName})`} + {`${igpQuote?.weiAmount || '0'} ${originNativeTokenSymbol}`} +

+ )}
@@ -448,6 +480,7 @@ function validateFormValues( values: TransferFormValues, tokenRoutes: RoutesMap, balances: AppState['balances'], + igpQuote: AppState['igpQuote'], ) { const { originCaip2Id, destinationCaip2Id, amount, tokenCaip19Id, recipientAddress } = values; const route = getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes); @@ -472,7 +505,16 @@ function validateFormValues( if (!isNft) { // Validate balances for ERC20-like tokens - if (sendValue.gt(balances.senderBalance)) return { amount: 'Insufficient balance' }; + if (sendValue.gt(balances.senderTokenBalance)) return { amount: 'Insufficient balance' }; + // Ensure balances can cover IGP fees + const igpWeiAmount = new BigNumber(igpQuote?.weiAmount || 0); + const requiredNativeBalance = isRouteFromNative(route) + ? sendValue.plus(igpWeiAmount) + : igpWeiAmount; + if (requiredNativeBalance.gt(balances.senderNativeBalance)) { + toastIgpDetails(); + return { amount: 'Insufficient native token for gas' }; + } } else { // Validate balances for ERC721-like tokens const { isSenderNftOwner, senderNftIds } = balances; diff --git a/src/features/transfer/useIgpQuote.ts b/src/features/transfer/useIgpQuote.ts new file mode 100644 index 0000000..89f2b79 --- /dev/null +++ b/src/features/transfer/useIgpQuote.ts @@ -0,0 +1,52 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +import { ProtocolType } from '@hyperlane-xyz/utils'; + +import { getChainReference, getProtocolType } from '../caip/chains'; +import { getMultiProvider } from '../multiProvider'; +import { useStore } from '../store'; +import { AdapterFactory } from '../tokens/AdapterFactory'; +import { Route } from '../tokens/routes/types'; + +const NON_EVM_IGP_QUOTE = '10000'; + +export function useIgpQuote(route?: Route, enabled = true) { + const setIgpQuote = useStore((state) => state.setIgpQuote); + + const { + isLoading, + isError: hasError, + data, + } = useQuery({ + queryKey: ['useIgpQuote', route], + queryFn: async () => { + if (!route) return null; + + const originProtocol = getProtocolType(route.originCaip2Id); + if (originProtocol !== ProtocolType.Ethereum) + return { + weiAmount: NON_EVM_IGP_QUOTE, + originCaip2Id: route.originCaip2Id, + destinationCaip2Id: route.destCaip2Id, + }; + + const adapter = AdapterFactory.HypTokenAdapterFromRouteOrigin(route); + const destinationChainId = getChainReference(route.destCaip2Id); + const destinationDomainId = getMultiProvider().getDomainId(destinationChainId); + const weiAmount = await adapter.quoteGasPayment(destinationDomainId); + return { + weiAmount, + originCaip2Id: route.originCaip2Id, + destinationCaip2Id: route.destCaip2Id, + }; + }, + enabled, + }); + + useEffect(() => { + setIgpQuote(data || null); + }, [data, setIgpQuote]); + + return { isLoading, hasError, igpQuote: data }; +} diff --git a/src/features/transfer/useTokenTransfer.ts b/src/features/transfer/useTokenTransfer.ts index f57ee62..b29f68c 100644 --- a/src/features/transfer/useTokenTransfer.ts +++ b/src/features/transfer/useTokenTransfer.ts @@ -10,13 +10,13 @@ import { ProtocolType, convertDecimals, toWei } from '@hyperlane-xyz/utils'; import { toastTxSuccess } from '../../components/toast/TxSuccessToast'; import { logger } from '../../utils/logger'; import { parseCaip2Id } from '../caip/chains'; -import { isNativeToken, isNonFungibleToken } from '../caip/tokens'; +import { isNonFungibleToken } from '../caip/tokens'; import { getMultiProvider } from '../multiProvider'; import { AppState, useStore } from '../store'; import { AdapterFactory } from '../tokens/AdapterFactory'; import { isApproveRequired } from '../tokens/approval'; import { Route, RoutesMap } from '../tokens/routes/types'; -import { getTokenRoute, isRouteFromCollateral, isRouteToCollateral } from '../tokens/routes/utils'; +import { getTokenRoute, isRouteFromNative, isRouteToCollateral } from '../tokens/routes/utils'; import { AccountInfo, ActiveChainInfo, @@ -112,7 +112,7 @@ async function executeTransfer({ if (!tokenRoute) throw new Error('No token route found between chains'); const isNft = isNonFungibleToken(tokenCaip19Id); - const weiAmountOrId = isNft ? amount : toWei(amount, tokenRoute.originDecimals).toString(); + const weiAmountOrId = isNft ? amount : toWei(amount, tokenRoute.originDecimals).toFixed(0); const activeAccountAddress = activeAccounts.accounts[originProtocol]?.address || ''; addTransfer({ @@ -249,10 +249,9 @@ async function executeEvmTransfer({ const gasPayment = await hypTokenAdapter.quoteGasPayment(destinationDomainId); logger.debug('Quoted gas payment', gasPayment); // If sending native tokens (e.g. Eth), the gasPayment must be added to the tx value and sent together - const txValue = - isRouteFromCollateral(tokenRoute) && isNativeToken(baseTokenCaip19Id) - ? BigNumber.from(gasPayment).add(weiAmountOrId) - : gasPayment; + const txValue = isRouteFromNative(tokenRoute) + ? BigNumber.from(gasPayment).add(weiAmountOrId) + : gasPayment; const transferTxRequest = (await hypTokenAdapter.populateTransferRemoteTx({ weiAmountOrId, recipient: recipientAddress,