From fbf7ac917eba61b4012557383cc4783185cd8da4 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Mon, 18 Dec 2023 15:29:11 +0100 Subject: [PATCH] feat: Show error on tx and safe creation if wallet balance is insufficient (#2986) * feat: Show error on tx and safe creation if wallet balance is insufficient * fix: Write tests for useWalletCanPay * fix: Write another test for ExecuteForm --- .../create/steps/ReviewStep/index.tsx | 8 ++ src/components/tx/BalanceInfo/index.tsx | 1 + .../tx/SignOrExecuteForm/ExecuteForm.tsx | 11 ++- .../__tests__/ExecuteForm.test.tsx | 20 +++++ src/hooks/__tests__/useWalletCanPay.test.ts | 80 +++++++++++++++++++ src/hooks/useWalletCanPay.ts | 24 ++++++ 6 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/hooks/__tests__/useWalletCanPay.test.ts create mode 100644 src/hooks/useWalletCanPay.ts diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index e899f25364..02805c0972 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -1,3 +1,5 @@ +import ErrorMessage from '@/components/tx/ErrorMessage' +import useWalletCanPay from '@/hooks/useWalletCanPay' import { useMemo, useState } from 'react' import { Button, Grid, Typography, Divider, Box, Alert } from '@mui/material' import { lightPalette } from '@safe-global/safe-react-components' @@ -124,6 +126,8 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps {isWrongChain && } + + {!walletCanPay && !willRelay && ( + Your connected wallet doesn't have enough funds to execute this transaction + )} diff --git a/src/components/tx/BalanceInfo/index.tsx b/src/components/tx/BalanceInfo/index.tsx index e11b104a1c..5b600d56d8 100644 --- a/src/components/tx/BalanceInfo/index.tsx +++ b/src/components/tx/BalanceInfo/index.tsx @@ -3,6 +3,7 @@ import css from './styles.module.css' import useWalletBalance from '@/hooks/wallets/useWalletBalance' import WalletBalance from '@/components/common/WalletBalance' +// TODO: Remove this component if not being used const BalanceInfo = () => { const [balance] = useWalletBalance() diff --git a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx index 8fc8a2dbda..9ef1abd217 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx +++ b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx @@ -1,4 +1,4 @@ -import BalanceInfo from '@/components/tx/BalanceInfo' +import useWalletCanPay from '@/hooks/useWalletCanPay' import madProps from '@/utils/mad-props' import { type ReactElement, type SyntheticEvent, useContext, useState } from 'react' import { CircularProgress, Box, Button, CardActions, Divider } from '@mui/material' @@ -107,6 +107,12 @@ export const ExecuteForm = ({ setTxFlow(, undefined, false) } + const walletCanPay = useWalletCanPay({ + gasLimit, + maxFeePerGas: advancedParams.maxFeePerGas, + maxPriorityFeePerGas: advancedParams.maxPriorityFeePerGas, + }) + const cannotPropose = !isOwner && !onlyExecute const submitDisabled = !safeTx || @@ -128,7 +134,6 @@ export const ExecuteForm = ({ gasLimitError={gasLimitError} willRelay={willRelay} /> - {!canRelay && } {canRelay && (
@@ -148,6 +153,8 @@ export const ExecuteForm = ({ Cannot execute a transaction from the Safe Account itself, please connect a different account. + ) : !walletCanPay && !willRelay ? ( + Your connected wallet doesn't have enough funds to execute this transaction. ) : ( (executionValidationError || gasLimitError) && ( diff --git a/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx b/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx index b03271e148..ab48c3c0e3 100644 --- a/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx +++ b/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx @@ -9,6 +9,7 @@ import * as useGasLimit from '@/hooks/useGasLimit' import * as useIsValidExecution from '@/hooks/useIsValidExecution' import * as useWalletCanRelay from '@/hooks/useWalletCanRelay' import * as relayUtils from '@/utils/relaying' +import * as walletCanPay from '@/hooks/useWalletCanPay' import { render } from '@/tests/test-utils' import { fireEvent, waitFor } from '@testing-library/react' @@ -70,6 +71,25 @@ describe.only('ExecuteForm', () => { ).toBeInTheDocument() }) + it('shows an error if the connected wallet has insufficient funds to execute and relaying is not selected', () => { + jest.spyOn(walletCanPay, 'default').mockReturnValue(false) + jest.spyOn(useWalletCanRelay, 'default').mockReturnValue([true, undefined, false]) + jest.spyOn(relayUtils, 'hasRemainingRelays').mockReturnValue(true) + + const { getByText, queryByText, getByTestId } = render() + + expect( + queryByText("Your connected wallet doesn't have enough funds to execute this transaction."), + ).not.toBeInTheDocument() + + const executeWithWalletOption = getByTestId('connected-wallet-execution-method') + fireEvent.click(executeWithWalletOption) + + expect( + getByText("Your connected wallet doesn't have enough funds to execute this transaction."), + ).toBeInTheDocument() + }) + it('shows a relaying option if relaying is enabled', () => { jest.spyOn(useWalletCanRelay, 'default').mockReturnValue([true, undefined, false]) jest.spyOn(relayUtils, 'hasRemainingRelays').mockReturnValue(true) diff --git a/src/hooks/__tests__/useWalletCanPay.test.ts b/src/hooks/__tests__/useWalletCanPay.test.ts new file mode 100644 index 0000000000..075c6c38dd --- /dev/null +++ b/src/hooks/__tests__/useWalletCanPay.test.ts @@ -0,0 +1,80 @@ +import useWalletCanPay from '@/hooks/useWalletCanPay' +import * as walletBalance from '@/hooks/wallets/useWalletBalance' +import { renderHook } from '@/tests/test-utils' +import { BigNumber } from 'ethers' + +describe('useWalletCanPay', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return true if gasLimit is missing', () => { + const { result } = renderHook(() => + useWalletCanPay({ maxFeePerGas: BigNumber.from(1), maxPriorityFeePerGas: BigNumber.from(1) }), + ) + + expect(result.current).toEqual(true) + }) + + it('should return true if maxFeePerGas is missing', () => { + const { result } = renderHook(() => + useWalletCanPay({ gasLimit: BigNumber.from(21000), maxPriorityFeePerGas: BigNumber.from(1) }), + ) + + expect(result.current).toEqual(true) + }) + + it('should return true if wallet balance is missing', () => { + jest.spyOn(walletBalance, 'default').mockReturnValue([undefined, undefined, false]) + + const { result } = renderHook(() => + useWalletCanPay({ gasLimit: BigNumber.from(21000), maxFeePerGas: BigNumber.from(1) }), + ) + + expect(result.current).toEqual(true) + }) + + it('should return false if wallet balance is smaller than gas costs', () => { + jest.spyOn(walletBalance, 'default').mockReturnValue([BigNumber.from(20999), undefined, false]) + + const { result } = renderHook(() => + useWalletCanPay({ gasLimit: BigNumber.from(21000), maxFeePerGas: BigNumber.from(1) }), + ) + + expect(result.current).toEqual(false) + }) + + it('should return true if wallet balance is larger or equal than gas costs', () => { + jest.spyOn(walletBalance, 'default').mockReturnValue([BigNumber.from(21000), undefined, false]) + + const { result } = renderHook(() => + useWalletCanPay({ gasLimit: BigNumber.from(21000), maxFeePerGas: BigNumber.from(1) }), + ) + + expect(result.current).toEqual(true) + }) + + it('should return true if wallet balance is larger or equal than gas costs', () => { + jest.spyOn(walletBalance, 'default').mockReturnValue([BigNumber.from(21001), undefined, false]) + + const { result } = renderHook(() => + useWalletCanPay({ gasLimit: BigNumber.from(21000), maxFeePerGas: BigNumber.from(1) }), + ) + + expect(result.current).toEqual(true) + }) + + it('should take maxPriorityFeePerGas into account', () => { + jest.spyOn(walletBalance, 'default').mockReturnValue([BigNumber.from(42000), undefined, false]) + + const { result } = renderHook(() => + useWalletCanPay({ + gasLimit: BigNumber.from(21000), + maxFeePerGas: BigNumber.from(1), + maxPriorityFeePerGas: BigNumber.from(1), + }), + ) + + expect(result.current).toEqual(true) + }) +}) diff --git a/src/hooks/useWalletCanPay.ts b/src/hooks/useWalletCanPay.ts new file mode 100644 index 0000000000..ee16d0fe8c --- /dev/null +++ b/src/hooks/useWalletCanPay.ts @@ -0,0 +1,24 @@ +import useWalletBalance from '@/hooks/wallets/useWalletBalance' +import { BigNumber } from 'ethers' + +const useWalletCanPay = ({ + gasLimit, + maxFeePerGas, + maxPriorityFeePerGas, +}: { + gasLimit?: BigNumber + maxFeePerGas?: BigNumber | null + maxPriorityFeePerGas?: BigNumber | null +}) => { + const [walletBalance] = useWalletBalance() + + // Take an optimistic approach and assume the wallet can pay + // if gasLimit, maxFeePerGas or their walletBalance are missing + if (!gasLimit || !maxFeePerGas || !walletBalance) return true + + const totalFee = maxFeePerGas.add(maxPriorityFeePerGas || BigNumber.from(0)).mul(gasLimit) + + return walletBalance.gte(totalFee) +} + +export default useWalletCanPay