Skip to content

Commit 089d4fd

Browse files
authored
fix(limit-twap): disable confirm when balance is insufficient (#7804)
Fixes #5645.
1 parent 8ab088d commit 089d4fd

7 files changed

Lines changed: 223 additions & 4 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { TokenWithLogo } from '@cowprotocol/common-const'
2+
import { getCurrencyAddress } from '@cowprotocol/common-utils'
3+
import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk'
4+
import { CurrencyAmount } from '@cowprotocol/currency'
5+
6+
import { renderHook } from '@testing-library/react'
7+
8+
import { useHasEnoughBalanceForAmount } from './useHasEnoughBalanceForAmount'
9+
import { useTokensBalancesCombined } from './useTokensBalancesCombined'
10+
11+
jest.mock('./useTokensBalancesCombined', () => ({
12+
useTokensBalancesCombined: jest.fn(),
13+
}))
14+
15+
const mockUseTokensBalancesCombined = useTokensBalancesCombined as jest.MockedFunction<typeof useTokensBalancesCombined>
16+
17+
const token = {
18+
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
19+
chainId: SupportedChainId.MAINNET,
20+
decimals: 6,
21+
symbol: 'USDC',
22+
name: 'USD Coin',
23+
} as TokenWithLogo
24+
25+
const balanceKey = getAddressKey(getCurrencyAddress(token))
26+
27+
function mockBalance(raw: string | null): void {
28+
mockUseTokensBalancesCombined.mockReturnValue({
29+
values: raw === null ? {} : { [balanceKey]: BigInt(raw) },
30+
} as unknown as ReturnType<typeof useTokensBalancesCombined>)
31+
}
32+
33+
// 1 USDC and 2 USDC
34+
const oneUsdc = CurrencyAmount.fromRawAmount(token, '1000000')
35+
const twoUsdc = CurrencyAmount.fromRawAmount(token, '2000000')
36+
37+
describe('useHasEnoughBalanceForAmount()', () => {
38+
it('returns true when there is no amount to check', () => {
39+
mockBalance('0')
40+
41+
const { result } = renderHook(() => useHasEnoughBalanceForAmount(null))
42+
43+
expect(result.current).toBe(true)
44+
})
45+
46+
it('returns true when the balance is greater than the amount', () => {
47+
mockBalance('2000000')
48+
49+
const { result } = renderHook(() => useHasEnoughBalanceForAmount(oneUsdc))
50+
51+
expect(result.current).toBe(true)
52+
})
53+
54+
it('returns true when the balance equals the amount', () => {
55+
mockBalance('1000000')
56+
57+
const { result } = renderHook(() => useHasEnoughBalanceForAmount(oneUsdc))
58+
59+
expect(result.current).toBe(true)
60+
})
61+
62+
it('returns false when the balance is less than the amount', () => {
63+
mockBalance('1000000')
64+
65+
const { result } = renderHook(() => useHasEnoughBalanceForAmount(twoUsdc))
66+
67+
expect(result.current).toBe(false)
68+
})
69+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useMemo } from 'react'
2+
3+
import { getCurrencyAddress } from '@cowprotocol/common-utils'
4+
import { getAddressKey } from '@cowprotocol/cow-sdk'
5+
import { Currency, CurrencyAmount } from '@cowprotocol/currency'
6+
import { Nullish } from '@cowprotocol/types'
7+
8+
import { useTokensBalancesCombined } from './useTokensBalancesCombined'
9+
10+
/**
11+
* Re-checks, against live combined balances, whether the given amount can still be afforded.
12+
*
13+
* The confirmation modals freeze their amounts when opened, but the balance can still change
14+
* underneath them (e.g. a previous order fills and spends it). This lets those modals disable
15+
* the Confirm button when the order became unaffordable while the modal was open (issue #5645).
16+
*
17+
* Returns `true` when there is no amount to check, so the button is not blocked during loading.
18+
*/
19+
export function useHasEnoughBalanceForAmount(inputAmount: Nullish<CurrencyAmount<Currency>>): boolean {
20+
const { values: balances } = useTokensBalancesCombined()
21+
22+
return useMemo(() => {
23+
const currency = inputAmount?.currency
24+
25+
if (!currency || !inputAmount) return true
26+
27+
const balanceRaw = balances[getAddressKey(getCurrencyAddress(currency))]
28+
const balance = CurrencyAmount.fromRawAmount(currency, balanceRaw?.toString() ?? '0')
29+
30+
return inputAmount.equalTo(balance) || inputAmount.lessThan(balance)
31+
}, [balances, inputAmount])
32+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { TokenWithLogo } from '@cowprotocol/common-const'
2+
import { getCurrencyAddress } from '@cowprotocol/common-utils'
3+
import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk'
4+
5+
import { renderHook } from '@testing-library/react'
6+
7+
import { useIsZeroBalance } from './useIsZeroBalance'
8+
import { useTokensBalancesCombined } from './useTokensBalancesCombined'
9+
10+
jest.mock('./useTokensBalancesCombined', () => ({
11+
useTokensBalancesCombined: jest.fn(),
12+
}))
13+
14+
const mockUseTokensBalancesCombined = useTokensBalancesCombined as jest.MockedFunction<typeof useTokensBalancesCombined>
15+
16+
const token = {
17+
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
18+
chainId: SupportedChainId.MAINNET,
19+
decimals: 6,
20+
symbol: 'USDC',
21+
name: 'USD Coin',
22+
} as TokenWithLogo
23+
24+
const balanceKey = getAddressKey(getCurrencyAddress(token))
25+
26+
function mockBalance(raw: string | null, hasFirstLoad = true): void {
27+
mockUseTokensBalancesCombined.mockReturnValue({
28+
values: raw === null ? {} : { [balanceKey]: BigInt(raw) },
29+
hasFirstLoad,
30+
} as unknown as ReturnType<typeof useTokensBalancesCombined>)
31+
}
32+
33+
describe('useIsZeroBalance()', () => {
34+
it('returns false when there is no currency to check', () => {
35+
mockBalance('0')
36+
37+
const { result } = renderHook(() => useIsZeroBalance(null))
38+
39+
expect(result.current).toBe(false)
40+
})
41+
42+
it('returns true when the balance is exactly zero', () => {
43+
mockBalance('0')
44+
45+
const { result } = renderHook(() => useIsZeroBalance(token))
46+
47+
expect(result.current).toBe(true)
48+
})
49+
50+
it('returns true when the token has no balance entry at all', () => {
51+
mockBalance(null)
52+
53+
const { result } = renderHook(() => useIsZeroBalance(token))
54+
55+
expect(result.current).toBe(true)
56+
})
57+
58+
it('returns false before balances have loaded, even with no balance entry', () => {
59+
mockBalance(null, false)
60+
61+
const { result } = renderHook(() => useIsZeroBalance(token))
62+
63+
expect(result.current).toBe(false)
64+
})
65+
66+
it('returns false when the balance is greater than zero (amount > balance is allowed for limit orders)', () => {
67+
mockBalance('1')
68+
69+
const { result } = renderHook(() => useIsZeroBalance(token))
70+
71+
expect(result.current).toBe(false)
72+
})
73+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useMemo } from 'react'
2+
3+
import { getCurrencyAddress } from '@cowprotocol/common-utils'
4+
import { getAddressKey } from '@cowprotocol/cow-sdk'
5+
import { Currency, CurrencyAmount } from '@cowprotocol/currency'
6+
import { Nullish } from '@cowprotocol/types'
7+
8+
import { useTokensBalancesCombined } from './useTokensBalancesCombined'
9+
10+
/**
11+
* Whether the given currency's live combined balance is exactly zero.
12+
*
13+
* Limit orders may be placed with an amount greater than the balance, so the confirm modal must
14+
* only block when the sell token balance drops to 0 (e.g. a previous order fully filled while the
15+
* modal was open), not whenever amount > balance (issue #5645).
16+
*
17+
* Returns `false` when there is no currency yet or before balances have loaded, so the button is
18+
* not blocked by a transient zero while `values` is still empty (issue #5645).
19+
*/
20+
export function useIsZeroBalance(currency: Nullish<Currency>): boolean {
21+
const { values: balances, hasFirstLoad } = useTokensBalancesCombined()
22+
23+
return useMemo(() => {
24+
if (!currency || !hasFirstLoad) return false
25+
26+
const balanceRaw = balances[getAddressKey(getCurrencyAddress(currency))]
27+
const balance = CurrencyAmount.fromRawAmount(currency, balanceRaw?.toString() ?? '0')
28+
29+
return balance.equalTo(0)
30+
}, [balances, hasFirstLoad, currency])
31+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from './hooks/useCurrencyAmountBalanceCombined'
2+
export * from './hooks/useHasEnoughBalanceForAmount'
3+
export * from './hooks/useIsZeroBalance'
24
export * from './hooks/useTokensBalancesCombined'
35
export { BalancesCombinedUpdater } from './updater/BalancesCombinedUpdater'

apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersConfirmModal/index.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Trans } from '@lingui/react/macro'
99

1010
import { PriceImpact } from 'legacy/hooks/usePriceImpact'
1111

12+
import { useIsZeroBalance } from 'modules/combinedBalances'
1213
import { LimitOrdersWarnings } from 'modules/limitOrders/containers/LimitOrdersWarnings'
1314
import { useHandleOrderPlacement } from 'modules/limitOrders/hooks/useHandleOrderPlacement'
1415
import { useLimitOrdersWarningsAccepted } from 'modules/limitOrders/hooks/useLimitOrdersWarningsAccepted'
@@ -70,10 +71,17 @@ export function LimitOrdersConfirmModal(props: LimitOrdersConfirmModalProps): Re
7071

7172
const doTrade = useHandleOrderPlacement(tradeContext, priceImpact, settingsState, tradeConfirmActions)
7273
const isTooLowRate = rateImpact < LOW_RATE_THRESHOLD_PERCENT
73-
const isConfirmDisabled = isTooLowRate ? !warningsAccepted : false
7474

75+
// Limit orders may be placed with amount > balance, so only block when the sell token balance
76+
// dropped to 0 while the modal was open (e.g. a previous order fully filled) — see issue #5645.
77+
const isInsufficientBalance = useIsZeroBalance(inputAmount?.currency)
78+
const isConfirmDisabled = (isTooLowRate ? !warningsAccepted : false) || isInsufficientBalance
79+
80+
const inputSymbol = inputAmount?.currency?.symbol || t`token`
7581
const isSafeApprovalBundle = useIsSafeApprovalBundle(inputAmount)
76-
const buttonText = isSafeApprovalBundle ? (
82+
const buttonText = isInsufficientBalance ? (
83+
t`Insufficient ${inputSymbol} balance`
84+
) : isSafeApprovalBundle ? (
7785
<>
7886
<Trans>Confirm</Trans> (<Trans>Approve</Trans>&nbsp;
7987
<TokenSymbol token={inputAmount && getWrappedToken(inputAmount.currency)} length={6} />

apps/cowswap-frontend/src/modules/twap/containers/TwapConfirmModal/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Trans } from '@lingui/react/macro'
77

88
import { useAdvancedOrdersDerivedState } from 'modules/advancedOrders'
99
import { AffiliateTraderRewardsRow, useIsRewardsRowEnabled } from 'modules/affiliate'
10+
import { useHasEnoughBalanceForAmount } from 'modules/combinedBalances'
1011
import {
1112
TradeConfirmation,
1213
TradeConfirmModal,
@@ -97,7 +98,10 @@ export function TwapConfirmModal() {
9798
const tradeConfirmActions = useTradeConfirmActions()
9899
const createTwapOrder = useCreateTwapOrder()
99100

100-
const isConfirmDisabled = !!localFormValidation
101+
// Re-check the balance against the (frozen) sell amount in case it changed while the modal was open
102+
const isInsufficientBalance = !useHasEnoughBalanceForAmount(inputCurrencyAmount)
103+
const isConfirmDisabled = !!localFormValidation || isInsufficientBalance
104+
const inputSymbol = inputCurrencyAmount?.currency?.symbol || t`token`
101105

102106
const priceImpact = useTradePriceImpact()
103107
const fallbackHandlerIsNotSet = useIsFallbackHandlerRequired()
@@ -134,7 +138,7 @@ export function TwapConfirmModal() {
134138
onDismiss={tradeConfirmActions.onDismiss}
135139
isConfirmDisabled={isConfirmDisabled}
136140
priceImpact={priceImpact}
137-
buttonText={t`Place TWAP order`}
141+
buttonText={isInsufficientBalance ? t`Insufficient ${inputSymbol} balance` : t`Place TWAP order`}
138142
recipient={recipient}
139143
>
140144
{(warnings) => (

0 commit comments

Comments
 (0)