Finish IGP refactor

pull/101/head
J M Rossy 10 months ago
parent c93612b48d
commit 8a133e6b42
  1. 1
      .eslintrc
  2. 2
      package.json
  3. 118
      src/features/transfer/TransferTokenForm.tsx
  4. 1
      src/features/transfer/types.ts
  5. 7
      src/features/transfer/useIgpQuote.ts
  6. 202
      src/features/transfer/validateForm.ts
  7. 25
      yarn.lock

@ -27,7 +27,6 @@
"@typescript-eslint/ban-ts-comment": ["off"],
"@typescript-eslint/explicit-module-boundary-types": ["off"],
"@typescript-eslint/no-explicit-any": ["off"],
"@typescript-eslint/no-floating-promises": ["error"],
"@typescript-eslint/no-non-null-assertion": ["off"],
"@typescript-eslint/no-require-imports": ["warn"],
"jsx-a11y/alt-text": ["off"]

@ -31,7 +31,7 @@
"bignumber.js": "^9.1.1",
"buffer": "^6.0.3",
"cosmjs-types": "^0.9.0",
"formik": "^2.2.9",
"formik": "^2.4.5",
"framer-motion": "^10.16.4",
"next": "^13.2.4",
"react": "^18.2.0",

@ -8,10 +8,7 @@ import {
ProtocolType,
fromWei,
fromWeiRounded,
isValidAddress,
isZeroishAddress,
toWei,
tryParseAmount,
} from '@hyperlane-xyz/utils';
import { SmallSpinner } from '../../components/animation/SmallSpinner';
@ -21,17 +18,14 @@ 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 { toastIgpDetails } from '../../components/toast/IgpDetailsToast';
import { config } from '../../consts/config';
import SwapIcon from '../../images/icons/swap.svg';
import { Color } from '../../styles/Color';
import { logger } from '../../utils/logger';
import { getProtocolType } from '../caip/chains';
import { getTokenAddress, isNonFungibleToken, parseCaip19Id } from '../caip/tokens';
import { getTokenAddress, isNonFungibleToken } from '../caip/tokens';
import { ChainSelectField } from '../chains/ChainSelectField';
import { getChainDisplayName } from '../chains/utils';
import { getChainMetadata } from '../multiProvider';
import { AppState, useStore } from '../store';
import { useStore } from '../store';
import { SelectOrInputTokenIds } from '../tokens/SelectOrInputTokenIds';
import { TokenSelectField } from '../tokens/TokenSelectField';
import { useIsApproveRequired } from '../tokens/approval';
@ -40,15 +34,17 @@ import { getToken } from '../tokens/metadata';
import { useRouteChains } from '../tokens/routes/hooks';
import { RoutesMap, WarpRoute } from '../tokens/routes/types';
import { getTokenRoute, isIbcOnlyRoute } from '../tokens/routes/utils';
import { useAccountAddressForChain } from '../wallet/hooks/multiProtocol';
import { useAccountAddressForChain, useAccounts } from '../wallet/hooks/multiProtocol';
import { IgpQuote, TransferFormValues } from './types';
import { TransferFormValues } from './types';
import { useIgpQuote } from './useIgpQuote';
import { useTokenTransfer } from './useTokenTransfer';
import { validateFormValues } from './validateForm';
export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
const chainCaip2Ids = useRouteChains(tokenRoutes);
const initialValues = useFormInitialValues(chainCaip2Ids, tokenRoutes);
const { accounts } = useAccounts();
// Flag for if form is in input vs review mode
const [isReview, setIsReview] = useState(false);
@ -61,7 +57,7 @@ export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
}));
const validate = (values: TransferFormValues) =>
validateFormValues(values, tokenRoutes, balances, igpQuote);
validateFormValues(values, tokenRoutes, balances, igpQuote, accounts);
const onSubmitForm = (values: TransferFormValues) => {
logger.debug('Reviewing transfer form values:', JSON.stringify(values));
@ -490,106 +486,6 @@ function ReviewDetails({ visible, tokenRoutes }: { visible: boolean; tokenRoutes
);
}
function validateFormValues(
values: TransferFormValues,
tokenRoutes: RoutesMap,
balances: AppState['balances'],
igpQuote: IgpQuote,
) {
const { originCaip2Id, destinationCaip2Id, amount, tokenCaip19Id, recipientAddress } = values;
const route = getTokenRoute(originCaip2Id, destinationCaip2Id, tokenCaip19Id, tokenRoutes);
if (!route) return { destinationCaip2Id: 'No route found for chains/token' };
if (!originCaip2Id) return { originCaip2Id: 'Invalid origin chain' };
if (!destinationCaip2Id) return { destinationCaip2Id: 'Invalid destination chain' };
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' };
const originProtocol = getProtocolType(originCaip2Id);
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` };
}
}
const isNft = isNonFungibleToken(tokenCaip19Id);
const parsedAmount = tryParseAmount(amount);
if (!parsedAmount || parsedAmount.lte(0))
return { amount: isNft ? 'Invalid Token Id' : 'Invalid amount' };
const sendValue = isNft
? parsedAmount
: new BigNumber(toWei(parsedAmount, route?.originDecimals));
if (!isNft) {
// Validate balances for ERC20-like tokens
if (sendValue.gt(balances.senderTokenBalance)) return { amount: 'Insufficient balance' };
// Ensure balances can cover IGP fees
if (!igpQuote?.weiAmount) return { amount: 'Interchain gas quote not ready' };
const igpWeiAmount = new BigNumber(igpQuote.weiAmount);
const { symbol: igpTokenSymbol, tokenCaip19Id: igpTokenCaip19Id } = igpQuote.token;
// TODO Need a way to get the right balances from the CAIP19id
const userIgpTokenBalance = balances[igpTokenCaip19Id];
// TODO Then fix this to compare the igp token with the send token
const requiredIgpTokenBalance =
route.o || originProtocol === ProtocolType.Cosmos
? sendValue.plus(igpWeiAmount)
: igpWeiAmount;
// TODO Then clean this up to use the right token metadata
// May need to get the igp token decimals in the quote for this part
const nativeToken = getChainMetadata(originCaip2Id)?.nativeToken;
const nativeDecimals = nativeToken?.decimals || 18;
const gasTokenSymbol =
(originProtocol === ProtocolType.Cosmos ? tokenMetadata.symbol : nativeToken?.symbol) ||
'native token';
const igpAmountPretty = fromWei(igpWeiAmount, nativeDecimals);
if (requiredIgpTokenBalance.gt(userIgpTokenBalance)) {
toastIgpDetails(igpAmountPretty, gasTokenSymbol);
return { amount: `Insufficient ${gasTokenSymbol} for gas` };
}
} else {
// Validate balances for ERC721-like tokens
const { isSenderNftOwner, senderNftIds } = balances;
const nftId = sendValue.toString();
if (isSenderNftOwner === false || (senderNftIds && !senderNftIds.includes(nftId))) {
return { amount: 'Token ID not owned' };
}
}
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 {};
}
function useFormInitialValues(
chainCaip2Ids: ChainCaip2Id[],
tokenRoutes: RoutesMap,

@ -43,5 +43,6 @@ export interface IgpQuote {
token: {
tokenCaip19Id: TokenCaip19Id;
symbol: string;
decimals: number;
};
}

@ -54,16 +54,19 @@ export function useIgpQuote(route?: Route, enabled = true) {
const baseTokenProtocol = getProtocolType(getChainIdFromToken(baseTokenCaip19Id));
let igpTokenCaip19Id: TokenCaip19Id;
let igpTokenSymbol: string;
let igpTokenDecimals: number;
if (baseToken.igpTokenAddress) {
// If the token has an explicit IGP token address set, use that
const igpToken = findTokensByAddress(baseToken.igpTokenAddress)[0];
igpTokenCaip19Id = igpToken.tokenCaip19Id;
// Note this assumes the u prefix because only cosmos tokens use this case
igpTokenSymbol = `u${igpToken.symbol}`;
igpTokenDecimals = igpToken.decimals;
} else if (baseTokenProtocol === ProtocolType.Cosmos) {
// If the protocol is cosmos, use the base token but with a u prefix
igpTokenCaip19Id = baseTokenCaip19Id;
igpTokenCaip19Id = baseToken.tokenCaip19Id;
igpTokenSymbol = `u${baseToken.symbol}`;
igpTokenDecimals = baseToken.decimals;
} else {
// Otherwise use the plain old native token from the route origin
const originNativeToken = getChainMetadata(originCaip2Id).nativeToken;
@ -74,6 +77,7 @@ export function useIgpQuote(route?: Route, enabled = true) {
getNativeTokenAddress(originProtocol),
);
igpTokenSymbol = originNativeToken.symbol;
igpTokenDecimals = originNativeToken.decimals;
}
return {
@ -83,6 +87,7 @@ export function useIgpQuote(route?: Route, enabled = true) {
token: {
tokenCaip19Id: igpTokenCaip19Id,
symbol: igpTokenSymbol,
decimals: igpTokenDecimals,
},
};
},

@ -0,0 +1,202 @@
import BigNumber from 'bignumber.js';
import { toast } from 'react-toastify';
import {
ProtocolType,
fromWei,
isValidAddress,
isZeroishAddress,
toWei,
tryParseAmount,
} from '@hyperlane-xyz/utils';
import { toastIgpDetails } from '../../components/toast/IgpDetailsToast';
import { config } from '../../consts/config';
import { getProtocolType } from '../caip/chains';
import { isNativeToken, isNonFungibleToken, parseCaip19Id } from '../caip/tokens';
import { getChainMetadata } from '../multiProvider';
import { AppState } from '../store';
import { AdapterFactory } from '../tokens/AdapterFactory';
import { getToken } from '../tokens/metadata';
import { Route, RoutesMap } from '../tokens/routes/types';
import { getTokenRoute } from '../tokens/routes/utils';
import { getAccountAddressForChain } from '../wallet/hooks/multiProtocol';
import { AccountInfo } from '../wallet/hooks/types';
import { IgpQuote, 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 originProtocol = getProtocolType(originCaip2Id);
// const destProtocol = getProtocolType(destinationCaip2Id);
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
if (!igpQuote?.weiAmount) return { amount: 'Interchain gas quote not ready' };
const igpWeiAmount = new BigNumber(igpQuote.weiAmount);
const {
symbol: igpTokenSymbol,
tokenCaip19Id: igpTokenCaip19Id,
decimals: igpTokenDecimals,
} = igpQuote.token;
let igpTokenBalance: string;
if (igpTokenCaip19Id === route.baseTokenCaip19Id) {
igpTokenBalance = balances.senderTokenBalance;
} else if (isNativeToken(igpTokenCaip19Id)) {
igpTokenBalance = balances.senderNativeBalance;
} else {
const account = accounts[getProtocolType(route.originCaip2Id)];
const sender = getAccountAddressForChain(route.originCaip2Id, account);
if (!sender) return { amount: 'No sender address found' };
const adapter = AdapterFactory.TokenAdapterFromAddress(igpTokenCaip19Id);
igpTokenBalance = await adapter.getBalance(sender);
}
const requiredIgpTokenBalance =
igpTokenCaip19Id === route.baseTokenCaip19Id ? sendValue.plus(igpWeiAmount) : igpWeiAmount;
if (requiredIgpTokenBalance.gt(igpTokenBalance)) {
const igpAmountPretty = fromWei(igpWeiAmount, igpTokenDecimals);
toastIgpDetails(igpAmountPretty, igpTokenSymbol);
return { amount: `Insufficient ${igpTokenSymbol} for gas` };
}
return null;
}

@ -3093,7 +3093,7 @@ __metadata:
eslint: "npm:^8.41.0"
eslint-config-next: "npm:^13.4.3"
eslint-config-prettier: "npm:^8.8.0"
formik: "npm:^2.2.9"
formik: "npm:^2.4.5"
framer-motion: "npm:^10.16.4"
jest: "npm:^29.6.3"
jest-transform-yaml: "npm:^1.1.2"
@ -5894,6 +5894,16 @@ __metadata:
languageName: node
linkType: hard
"@types/hoist-non-react-statics@npm:^3.3.1":
version: 3.3.5
resolution: "@types/hoist-non-react-statics@npm:3.3.5"
dependencies:
"@types/react": "npm:*"
hoist-non-react-statics: "npm:^3.3.0"
checksum: b645b062a20cce6ab1245ada8274051d8e2e0b2ee5c6bd58215281d0ec6dae2f26631af4e2e7c8abe238cdcee73fcaededc429eef569e70908f82d0cc0ea31d7
languageName: node
linkType: hard
"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1":
version: 2.0.4
resolution: "@types/istanbul-lib-coverage@npm:2.0.4"
@ -9945,20 +9955,21 @@ __metadata:
languageName: node
linkType: hard
"formik@npm:^2.2.9":
version: 2.2.9
resolution: "formik@npm:2.2.9"
"formik@npm:^2.4.5":
version: 2.4.5
resolution: "formik@npm:2.4.5"
dependencies:
"@types/hoist-non-react-statics": "npm:^3.3.1"
deepmerge: "npm:^2.1.1"
hoist-non-react-statics: "npm:^3.3.0"
lodash: "npm:^4.17.21"
lodash-es: "npm:^4.17.21"
react-fast-compare: "npm:^2.0.1"
tiny-warning: "npm:^1.0.2"
tslib: "npm:^1.10.0"
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
checksum: c7b4c6ee9cc8256302c56106fa4ff1a86b48774b26e5cdd4fe5a40d5ae2171ed7a02714cd36fa7fb05d8b86c5471643339a69d6e3f4234a963e70b9208cbee82
checksum: 223fb3e6b0a7803221c030364a015b9adb01b61f7aed7c64e28ef8341a3e7c94c7a70aef7ed9f65d03ac44e4e19972c1247fb0e39538e4e084833fd1fa3b11c4
languageName: node
linkType: hard
@ -14893,7 +14904,7 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:1.14.1, tslib@npm:^1.10.0, tslib@npm:^1.8.1, tslib@npm:^1.9.0":
"tslib@npm:1.14.1, tslib@npm:^1.8.1, tslib@npm:^1.9.0":
version: 1.14.1
resolution: "tslib@npm:1.14.1"
checksum: 7dbf34e6f55c6492637adb81b555af5e3b4f9cc6b998fb440dac82d3b42bdc91560a35a5fb75e20e24a076c651438234da6743d139e4feabf0783f3cdfe1dddb

Loading…
Cancel
Save