Skip to content

Commit

Permalink
feat(frontend): validate account balance on transfer submit (#249)
Browse files Browse the repository at this point in the history
  • Loading branch information
nikitayutanov authored Dec 12, 2024
1 parent 61355db commit c19f7d7
Show file tree
Hide file tree
Showing 13 changed files with 281 additions and 122 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CSSProperties } from 'react';
import { cx } from '@/utils';

import { UseHandleSubmit } from '../../types';
import { getErrorMessage } from '../../utils';

import styles from './submit-progress-bar.module.scss';

Expand Down Expand Up @@ -38,10 +39,7 @@ const ERROR_TEXT = {

function SubmitProgressBar({ mint, approve, submit }: Props) {
const { isSuccess, isPending, error } = submit;

// string is only for cancelled sign and send popup error during useSendProgramTransaction
// reevaluate after @gear-js/react-hooks update
const errorMessage = typeof error === 'string' ? error : error?.message;
const errorMessage = error ? getErrorMessage(error) : '';

const getStatus = () => {
if (mint?.isPending || mint?.error) return 'mint';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
gap: 8px;

.select {
flex-shrink: 0;
min-width: 95px;

// TODO: temp fix for https://github.com/gear-tech/gear-js/issues/1705
Expand Down
31 changes: 20 additions & 11 deletions frontend/src/features/swap/components/swap-form/swap-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { TransactionModal } from '@/features/history/components/transaction-moda
import { Network as TransferNetwork } from '@/features/history/types';
import { NetworkWalletField } from '@/features/wallet';
import { useEthAccount } from '@/hooks';
import { cx } from '@/utils';
import { cx, isUndefined } from '@/utils';

import WalletSVG from '../../assets/wallet.svg?react';
import { FIELD_NAME, NETWORK_INDEX } from '../../consts';
Expand Down Expand Up @@ -57,13 +57,6 @@ function SwapForm({
const ftBalance = useFTBalance(address, decimals);
const allowance = useFTAllowance(address);

const [{ mutateAsync: onSubmit, ...submit }, approve, mint] = useHandleSubmit(
address,
fee.value,
allowance.data,
ftBalance.value,
);

const { account } = useAccount();
const ethAccount = useEthAccount();
const [transactionModal, setTransactionModal] = useState<ComponentProps<typeof TransactionModal> | undefined>();
Expand All @@ -81,16 +74,23 @@ function SwapForm({
setTransactionModal({ amount, source, destination, sourceNetwork, destNetwork, sender, receiver, close });
};

const [{ mutateAsync: onSubmit, ...submit }, approve, mint] = useHandleSubmit(
address,
fee.value,
allowance.data,
ftBalance.value,
accountBalance.value,
openTransacionModal,
);

const { form, amount, handleSubmit, setMaxBalance } = useSwapForm(
isVaraNetwork,
isNativeToken,
accountBalance,
ftBalance,
decimals,
fee.value,
disabled,
onSubmit,
openTransacionModal,
);

const renderFromBalance = () => {
Expand All @@ -106,7 +106,16 @@ function SwapForm({
);
};

const isBalanceValid = () => {
if (accountBalance.isLoading || config.isLoading) return true;
if (!accountBalance.value || isUndefined(fee.value)) return false;

return accountBalance.value > fee.value;
};

const getButtonText = () => {
if (!isBalanceValid()) return isVaraNetwork ? 'Not enough VARA' : 'Not enough ETH';

if (mint?.isPending) return 'Locking...';
if (approve.isPending) return 'Approving...';
if (submit.isPending) return 'Swapping...';
Expand Down Expand Up @@ -189,7 +198,7 @@ function SwapForm({
type="submit"
text={getButtonText()}
size="small"
disabled={disabled}
disabled={disabled || !isBalanceValid()}
isLoading={
approve.isLoading ||
submit.isPending ||
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/swap/consts/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const DEFAULT_VALUES = {

const ERROR_MESSAGE = {
NO_FT_BALANCE: 'Insufficient token balance',
NO_ACCOUNT_BALANCE: 'Insufficient account balance to pay fee',
NO_ACCOUNT_BALANCE: 'Insufficient account balance',
INVALID_ADDRESS: 'Invalid address',
MIN_AMOUNT: 'Amount is less than fee',
} as const;
Expand Down
21 changes: 16 additions & 5 deletions frontend/src/features/swap/hooks/eth/use-approve.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { HexString } from '@gear-js/api';
import { useMutation } from '@tanstack/react-query';
import { WatchContractEventOnLogsParameter } from 'viem';
import { encodeFunctionData, WatchContractEventOnLogsParameter } from 'viem';
import { useConfig, useWriteContract } from 'wagmi';
import { watchContractEvent } from 'wagmi/actions';
import { estimateGas, watchContractEvent } from 'wagmi/actions';

import { FUNGIBLE_TOKEN_ABI } from '@/consts';
import { useEthAccount } from '@/hooks';
Expand Down Expand Up @@ -41,16 +41,27 @@ function useApprove(address: HexString | undefined) {
const unwatch = watchContractEvent(config, { address, abi, eventName, args, onLogs, onError });
});

const approve = async (amount: bigint) => {
const getGasLimit = (amount: bigint) => {
if (!address) throw new Error('Fungible token address is not defined');

const functionName = FUNCTION_NAME.FUNGIBLE_TOKEN_APPROVE;
const args = [ETH_BRIDGING_PAYMENT_CONTRACT_ADDRESS, amount] as const;
const to = address;
const data = encodeFunctionData({ abi, functionName, args });

return writeContractAsync({ address, abi, functionName, args }).then(() => watch(amount));
return estimateGas(config, { to, data });
};

return useMutation({ mutationFn: approve });
const approve = async ({ amount, gas }: { amount: bigint; gas: bigint }) => {
if (!address) throw new Error('Fungible token address is not defined');

const functionName = FUNCTION_NAME.FUNGIBLE_TOKEN_APPROVE;
const args = [ETH_BRIDGING_PAYMENT_CONTRACT_ADDRESS, amount] as const;

return writeContractAsync({ address, abi, functionName, args, gas }).then(() => watch(amount));
};

return { ...useMutation({ mutationFn: approve }), getGasLimit };
}

export { useApprove };
73 changes: 65 additions & 8 deletions frontend/src/features/swap/hooks/eth/use-handle-eth-submit.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,75 @@
import { HexString } from '@gear-js/api';
import { useMutation } from '@tanstack/react-query';
import { encodeFunctionData } from 'viem';
import { useConfig, useWriteContract } from 'wagmi';
import { watchContractEvent } from 'wagmi/actions';
import { estimateFeesPerGas, estimateGas, watchContractEvent } from 'wagmi/actions';

import { isUndefined } from '@/utils';

import { BRIDGING_PAYMENT_ABI, ETH_BRIDGING_PAYMENT_CONTRACT_ADDRESS } from '../../consts';
import { BRIDGING_PAYMENT_ABI, ERROR_MESSAGE, ETH_BRIDGING_PAYMENT_CONTRACT_ADDRESS } from '../../consts';
import { FormattedValues } from '../../types';

import { useApprove } from './use-approve';

function useHandleEthSubmit(ftAddress: HexString | undefined, fee: bigint | undefined, allowance: bigint | undefined) {
const TRANSFER_GAS_LIMIT_FALLBACK = 21000n * 10n;

function useHandleEthSubmit(
ftAddress: HexString | undefined,
fee: bigint | undefined,
allowance: bigint | undefined,
_ftBalance: bigint | undefined,
accountBalance: bigint | undefined,
openTransactionModal: (amount: string, receiver: string) => void,
) {
const { writeContractAsync } = useWriteContract();
const approve = useApprove(ftAddress);
const config = useConfig();

const requestBridging = (amount: bigint, accountAddress: HexString) => {
const getTransferGasLimit = (amount: bigint, accountAddress: HexString) => {
if (!ftAddress) throw new Error('Fungible token address is not defined');

const encodedData = encodeFunctionData({
abi: BRIDGING_PAYMENT_ABI,
functionName: 'requestBridging',
args: [ftAddress, amount, accountAddress],
});

return estimateGas(config, {
to: ETH_BRIDGING_PAYMENT_CONTRACT_ADDRESS,
data: encodedData,
value: fee,
});
};

const validateBalance = async (amount: bigint, accountAddress: HexString) => {
if (!ftAddress) throw new Error('Fungible token address is not defined');
if (isUndefined(fee)) throw new Error('Fee is not defined');
if (isUndefined(allowance)) throw new Error('Allowance is not defined');
if (isUndefined(accountBalance)) throw new Error('Account balance is not defined');

const isApproveRequired = amount > allowance;
const approveGasLimit = isApproveRequired ? await approve.getGasLimit(amount) : BigInt(0);

// if approve is not made, transfer gas estimate will fail.
// it can be avoided by using stateOverride,
// but it requires the knowledge of the storage slot or state diff of the allowance for each token,
// which is not feasible to do programmatically (at least I didn't managed to find a convenient way to do so).
const transferGasLimit = isApproveRequired ? undefined : await getTransferGasLimit(amount, accountAddress);

// TRANSFER_GAS_LIMIT_FALLBACK is just for balance check, during the actual transfer it will be recalculated
const gasLimit = approveGasLimit + (transferGasLimit || TRANSFER_GAS_LIMIT_FALLBACK);

const { maxFeePerGas } = await estimateFeesPerGas(config);
const weiGasLimit = gasLimit * maxFeePerGas;

const balanceToWithdraw = weiGasLimit + fee;

if (balanceToWithdraw > accountBalance) throw new Error(ERROR_MESSAGE.NO_ACCOUNT_BALANCE);

return { isApproveRequired, approveGasLimit, transferGasLimit };
};

const transfer = async (amount: bigint, accountAddress: HexString, gasLimit: bigint | undefined) => {
if (!ftAddress) throw new Error('Fungible token address is not defined');
if (!fee) throw new Error('Fee is not defined');

Expand All @@ -25,6 +79,7 @@ function useHandleEthSubmit(ftAddress: HexString | undefined, fee: bigint | unde
functionName: 'requestBridging',
args: [ftAddress, amount, accountAddress],
value: fee,
gas: gasLimit,
});
};

Expand All @@ -47,15 +102,17 @@ function useHandleEthSubmit(ftAddress: HexString | undefined, fee: bigint | unde
});

const onSubmit = async ({ amount, accountAddress }: FormattedValues) => {
if (isUndefined(allowance)) throw new Error('Allowance is not defined');
const { isApproveRequired, approveGasLimit, transferGasLimit } = await validateBalance(amount, accountAddress);

openTransactionModal(amount.toString(), accountAddress);

if (amount > allowance) {
await approve.mutateAsync(amount);
if (isApproveRequired) {
await approve.mutateAsync({ amount, gas: approveGasLimit });
} else {
approve.reset();
}

return requestBridging(amount, accountAddress).then(() => watch());
return transfer(amount, accountAddress, transferGasLimit).then(() => watch());
};

const submit = useMutation({ mutationFn: onSubmit });
Expand Down
13 changes: 3 additions & 10 deletions frontend/src/features/swap/hooks/use-swap-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ import { useAlert } from '@gear-js/react-hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { BaseError } from 'wagmi';
import { WriteContractErrorType } from 'wagmi/actions';
import { z } from 'zod';

import { logger } from '@/utils';

import { FIELD_NAME, DEFAULT_VALUES, ADDRESS_SCHEMA } from '../consts';
import { FormattedValues } from '../types';
import { getAmountSchema, getMergedBalance } from '../utils';
import { getAmountSchema, getErrorMessage, getMergedBalance } from '../utils';

type Values = {
value: bigint | undefined;
Expand All @@ -24,14 +23,12 @@ function useSwapForm(
accountBalance: Values,
ftBalance: Values,
decimals: number | undefined,
fee: bigint | undefined,
disabled: boolean,
onSubmit: (values: FormattedValues) => Promise<unknown>,
openTransactionModal: (amount: string, receiver: string) => void,
) {
const alert = useAlert();

const valueSchema = getAmountSchema(isNativeToken, accountBalance.value, ftBalance.value, fee, decimals);
const valueSchema = getAmountSchema(isNativeToken, accountBalance.value, ftBalance.value, decimals);
const addressSchema = isVaraNetwork ? ADDRESS_SCHEMA.ETH : ADDRESS_SCHEMA.VARA;

const schema = z.object({
Expand All @@ -53,15 +50,11 @@ function useSwapForm(
alert.success('Transfer request is successful');
};

// string is only for cancelled sign and send popup error during useSendProgramTransaction
// reevaluate after @gear-js/react-hooks update
const onError = (error: WriteContractErrorType | string) => {
logger.error('Transfer Error', typeof error === 'string' ? new Error(error) : error);
alert.error(typeof error === 'string' ? error : (error as BaseError).shortMessage || error.message);
alert.error(getErrorMessage(error));
};

openTransactionModal(values[FIELD_NAME.VALUE].toString(), values[FIELD_NAME.ADDRESS]);

onSubmit(values).then(onSuccess).catch(onError);
});

Expand Down
22 changes: 22 additions & 0 deletions frontend/src/features/swap/hooks/vara/use-approve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { HexString } from '@gear-js/api';
import { useProgram, usePrepareProgramTransaction, useSendProgramTransaction } from '@gear-js/react-hooks';

import { VftProgram } from '@/consts';

import { SERVICE_NAME } from '../../consts';
import { FUNCTION_NAME } from '../../consts/vara';

function useApprove(ftAddress: HexString | undefined) {
const { data: program } = useProgram({
library: VftProgram,
id: ftAddress,
});

const params = { program, serviceName: SERVICE_NAME.VFT, functionName: FUNCTION_NAME.APPROVE };
const { prepareTransactionAsync } = usePrepareProgramTransaction(params);
const send = useSendProgramTransaction(params);

return { ...send, prepareTransactionAsync };
}

export { useApprove };
Loading

0 comments on commit c19f7d7

Please sign in to comment.