Skip to content

Commit 71fa043

Browse files
authored
fix(x/bank): Better handling of negative spendable balances (#21407)
1 parent f220f8b commit 71fa043

File tree

5 files changed

+68
-29
lines changed

5 files changed

+68
-29
lines changed

tests/integration/bank/keeper/deterministic_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ func TestGRPCQuerySpendableBalances(t *testing.T) {
266266
assert.NilError(t, err)
267267

268268
req := banktypes.NewQuerySpendableBalancesRequest(addr1Str, nil)
269-
testdata.DeterministicIterations(t, f.ctx, req, f.queryClient.SpendableBalances, 1777, false)
269+
testdata.DeterministicIterations(t, f.ctx, req, f.queryClient.SpendableBalances, 1420, false)
270270
}
271271

272272
func TestGRPCQueryTotalSupply(t *testing.T) {

x/bank/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ Ref: https://keepachangelog.com/en/1.0.0/
3636
* [#20517](https://github.com/cosmos/cosmos-sdk/pull/20517) `SendCoins` now checks for `SendRestrictions` before instead of after deducting coins using `subUnlockedCoins`.
3737
* [#20354](https://github.com/cosmos/cosmos-sdk/pull/20354) Reduce the number of `ValidateDenom` calls in `bank.SendCoins`.
3838

39+
### Bug Fixes
40+
41+
* [#21407](https://github.com/cosmos/cosmos-sdk/pull/21407) Fix handling of negative spendable balances.
42+
* The `SpendableBalances` query now correctly reports spendable balances when one or more denoms are negative (used to report all zeros). Also, this query now looks up only the balances for the requested page.
43+
* The `SpendableCoins` keeper method now returns the positive spendable balances even when one or more denoms have more locked than available (used to return an empty `Coins`).
44+
* The `SpendableCoin` keeper method now returns a zero coin if there's more locked than available (used to return a negative coin).
45+
3946
### API Breaking Changes
4047

4148
* [#19954](https://github.com/cosmos/cosmos-sdk/pull/19954) Removal of the Address.String() method and related changes:

x/bank/keeper/grpc_query.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,22 +92,25 @@ func (k BaseKeeper) SpendableBalances(ctx context.Context, req *types.QuerySpend
9292
}
9393

9494
zeroAmt := math.ZeroInt()
95-
96-
balances, pageRes, err := query.CollectionPaginate(ctx, k.Balances, req.Pagination, func(key collections.Pair[sdk.AccAddress, string], _ math.Int) (coin sdk.Coin, err error) {
97-
return sdk.NewCoin(key.K2(), zeroAmt), nil
95+
allLocked := k.LockedCoins(ctx, addr)
96+
97+
balances, pageRes, err := query.CollectionPaginate(ctx, k.Balances, req.Pagination, func(key collections.Pair[sdk.AccAddress, string], balanceAmt math.Int) (sdk.Coin, error) {
98+
denom := key.K2()
99+
coin := sdk.NewCoin(denom, zeroAmt)
100+
lockedAmt := allLocked.AmountOf(denom)
101+
switch {
102+
case !lockedAmt.IsPositive():
103+
coin.Amount = balanceAmt
104+
case lockedAmt.LT(balanceAmt):
105+
coin.Amount = balanceAmt.Sub(lockedAmt)
106+
}
107+
return coin, nil
98108
}, query.WithCollectionPaginationPairPrefix[sdk.AccAddress, string](addr))
99109
if err != nil {
100110
return nil, status.Errorf(codes.InvalidArgument, "paginate: %v", err)
101111
}
102112

103-
result := sdk.NewCoins()
104-
spendable := k.SpendableCoins(ctx, addr)
105-
106-
for _, c := range balances {
107-
result = append(result, sdk.NewCoin(c.Denom, spendable.AmountOf(c.Denom)))
108-
}
109-
110-
return &types.QuerySpendableBalancesResponse{Balances: result, Pagination: pageRes}, nil
113+
return &types.QuerySpendableBalancesResponse{Balances: balances, Pagination: pageRes}, nil
111114
}
112115

113116
// SpendableBalanceByDenom implements a gRPC query handler for retrieving an account's

x/bank/keeper/keeper_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,28 @@ func (suite *KeeperTestSuite) TestSpendableCoins() {
15271527

15281528
suite.mockSpendableCoins(ctx, vacc)
15291529
require.Equal(origCoins.Sub(lockedCoins...)[0], suite.bankKeeper.SpendableCoin(ctx, accAddrs[0], "stake"))
1530+
1531+
acc2 := authtypes.NewBaseAccountWithAddress(accAddrs[2])
1532+
lockedCoins2 := sdk.NewCoins(sdk.NewInt64Coin("stake", 50), sdk.NewInt64Coin("tarp", 40), sdk.NewInt64Coin("rope", 30))
1533+
balanceCoins2 := sdk.NewCoins(sdk.NewInt64Coin("stake", 49), sdk.NewInt64Coin("tarp", 40), sdk.NewInt64Coin("rope", 31), sdk.NewInt64Coin("pole", 20))
1534+
expCoins2 := sdk.NewCoins(sdk.NewInt64Coin("rope", 1), sdk.NewInt64Coin("pole", 20))
1535+
vacc2, err := vesting.NewPermanentLockedAccount(acc2, lockedCoins2)
1536+
suite.Require().NoError(err)
1537+
1538+
// Go back to the suite's context since mockFundAccount uses that; FundAccount would fail for bad mocking otherwise.
1539+
ctx = sdk.UnwrapSDKContext(suite.ctx)
1540+
suite.mockFundAccount(accAddrs[2])
1541+
require.NoError(banktestutil.FundAccount(ctx, suite.bankKeeper, accAddrs[2], balanceCoins2))
1542+
suite.mockSpendableCoins(ctx, vacc2)
1543+
require.Equal(expCoins2, suite.bankKeeper.SpendableCoins(ctx, accAddrs[2]))
1544+
suite.mockSpendableCoins(ctx, vacc2)
1545+
require.Equal(sdk.NewInt64Coin("stake", 0), suite.bankKeeper.SpendableCoin(ctx, accAddrs[2], "stake"))
1546+
suite.mockSpendableCoins(ctx, vacc2)
1547+
require.Equal(sdk.NewInt64Coin("tarp", 0), suite.bankKeeper.SpendableCoin(ctx, accAddrs[2], "tarp"))
1548+
suite.mockSpendableCoins(ctx, vacc2)
1549+
require.Equal(sdk.NewInt64Coin("rope", 1), suite.bankKeeper.SpendableCoin(ctx, accAddrs[2], "rope"))
1550+
suite.mockSpendableCoins(ctx, vacc2)
1551+
require.Equal(sdk.NewInt64Coin("pole", 20), suite.bankKeeper.SpendableCoin(ctx, accAddrs[2], "pole"))
15301552
}
15311553

15321554
func (suite *KeeperTestSuite) TestVestingAccountSend() {

x/bank/keeper/view.go

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,23 @@ func (k BaseViewKeeper) LockedCoins(ctx context.Context, addr sdk.AccAddress) sd
190190
// by address. If the account has no spendable coins, an empty Coins slice is
191191
// returned.
192192
func (k BaseViewKeeper) SpendableCoins(ctx context.Context, addr sdk.AccAddress) sdk.Coins {
193-
spendable, _ := k.spendableCoins(ctx, addr)
193+
total := k.GetAllBalances(ctx, addr)
194+
allLocked := k.LockedCoins(ctx, addr)
195+
if allLocked.IsZero() {
196+
return total
197+
}
198+
199+
unlocked, hasNeg := total.SafeSub(allLocked...)
200+
if !hasNeg {
201+
return unlocked
202+
}
203+
204+
spendable := sdk.Coins{}
205+
for _, coin := range unlocked {
206+
if coin.IsPositive() {
207+
spendable = append(spendable, coin)
208+
}
209+
}
194210
return spendable
195211
}
196212

@@ -199,23 +215,14 @@ func (k BaseViewKeeper) SpendableCoins(ctx context.Context, addr sdk.AccAddress)
199215
// is returned.
200216
func (k BaseViewKeeper) SpendableCoin(ctx context.Context, addr sdk.AccAddress, denom string) sdk.Coin {
201217
balance := k.GetBalance(ctx, addr, denom)
202-
locked := k.LockedCoins(ctx, addr)
203-
return balance.SubAmount(locked.AmountOf(denom))
204-
}
205-
206-
// spendableCoins returns the coins the given address can spend alongside the total amount of coins it holds.
207-
// It exists for gas efficiency, in order to avoid to have to get balance multiple times.
208-
func (k BaseViewKeeper) spendableCoins(ctx context.Context, addr sdk.AccAddress) (spendable, total sdk.Coins) {
209-
total = k.GetAllBalances(ctx, addr)
210-
locked := k.LockedCoins(ctx, addr)
211-
212-
spendable, hasNeg := total.SafeSub(locked...)
213-
if hasNeg {
214-
spendable = sdk.NewCoins()
215-
return
218+
lockedAmt := k.LockedCoins(ctx, addr).AmountOf(denom)
219+
if !lockedAmt.IsPositive() {
220+
return balance
216221
}
217-
218-
return
222+
if lockedAmt.LT(balance.Amount) {
223+
return balance.SubAmount(lockedAmt)
224+
}
225+
return sdk.NewCoin(denom, math.ZeroInt())
219226
}
220227

221228
// ValidateBalance validates all balances for a given account address returning

0 commit comments

Comments
 (0)