Merge pull request #62 from hyperlane-xyz/igp-fee-ui

Implement IGP fee details and validation
pull/43/merge
J M Rossy 1 year ago committed by GitHub
commit 00f2483192
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 21
      src/components/toast/IgpDetailsToast.tsx
  2. 1
      src/consts/links.ts
  3. 4
      src/features/multiProvider.ts
  4. 36
      src/features/store.ts
  5. 13
      src/features/tokens/AdapterFactory.ts
  6. 2
      src/features/tokens/approval.ts
  7. 31
      src/features/tokens/balances.ts
  8. 6
      src/features/tokens/routes/utils.ts
  9. 78
      src/features/transfer/TransferTokenForm.tsx
  10. 52
      src/features/transfer/useIgpQuote.ts
  11. 13
      src/features/transfer/useTokenTransfer.ts

@ -0,0 +1,21 @@
import { toast } from 'react-toastify';
import { links } from '../../consts/links';
export function toastIgpDetails() {
toast.error(<IgpDetailsToast />, {
autoClose: 5000,
});
}
export function IgpDetailsToast() {
return (
<div>
Cross-chain transfers require a small amount of extra gas to fund delivery. Your native token
balance is insufficient.{' '}
<a className="underline" href={links.gasDocs} target="_blank" rel="noopener noreferrer">
Learn More
</a>
</div>
);
}

@ -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',

@ -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);
}

@ -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<AppState>()(
@ -64,12 +77,15 @@ export const useStore = create<AppState>()(
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<AppState>()(
setIsSenderNftOwner: (isSenderNftOwner) => {
set((state) => ({ balances: { ...state.balances, isSenderNftOwner } }));
},
igpQuote: null,
setIgpQuote: (quote) => {
set(() => ({ igpQuote: quote }));
},
}),
{
name: 'app-state',

@ -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);

@ -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;

@ -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(

@ -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);
}

@ -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<TransferFormValues>();
const { balance, decimals } = useOriginBalance(values, tokenRoutes);
const { tokenBalance, tokenDecimals } = useOriginBalance(values, tokenRoutes);
return (
<div className="flex-1">
@ -198,7 +205,7 @@ function AmountSection({
<label htmlFor="amount" className="block uppercase text-sm text-gray-500 pl-0.5">
Amount
</label>
<TokenBalance label="My balance" balance={balance} decimals={decimals} />
<TokenBalance label="My balance" balance={tokenBalance} decimals={tokenDecimals} />
</div>
{isNft ? (
<SelectOrInputTokenIds disabled={isReview} tokenRoutes={tokenRoutes} />
@ -212,7 +219,7 @@ function AmountSection({
step="any"
disabled={isReview}
/>
<MaxButton disabled={isReview} balance={balance} decimals={decimals} />
<MaxButton disabled={isReview} balance={tokenBalance} decimals={tokenDecimals} />
</div>
)}
</div>
@ -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<TransferFormValues>();
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 (
<div
@ -422,7 +439,7 @@ function ReviewDetails({ visible, tokenRoutes }: { visible: boolean; tokenRoutes
<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>{`Token Address: ${getTokenAddress(token)}`}</p>
<p>{`Token Address: ${getTokenAddress(tokenCaip19Id)}`}</p>
<p>{`Collateral Address: ${route?.baseRouterAddress}`}</p>
</div>
</div>
@ -430,11 +447,26 @@ function ReviewDetails({ visible, tokenRoutes }: { visible: boolean; tokenRoutes
<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">
<p>{`Remote Token: ${route?.destRouterAddress}`}</p>
<p className="flex">
<span className="min-w-[7rem]">Remote Token</span>
<span>{route?.destRouterAddress}</span>
</p>
{isNft ? (
<p>{`Token ID: ${sendValue}`}</p>
<p className="flex">
<span className="min-w-[7rem]">Token ID</span>
<span>{sendValue}</span>
</p>
) : (
<p>{`Amount (${originUnitName}): ${sendValue}`}</p>
<>
<p className="flex">
<span className="min-w-[7rem]">{`Amount (${originUnitName})`}</span>
<span>{`${sendValue} ${originTokenSymbol}`}</span>
</p>
<p className="flex">
<span className="min-w-[7rem]">{`Interchain Gas (${originUnitName})`}</span>
<span>{`${igpQuote?.weiAmount || '0'} ${originNativeTokenSymbol}`}</span>
</p>
</>
)}
</div>
</div>
@ -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;

@ -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 };
}

@ -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,

Loading…
Cancel
Save