Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6d063ff
fix(bank): improve spendable coin calculation to account for locked c…
hoangdv2429 Mar 19, 2025
e413f78
no log
hoangdv2429 Mar 19, 2025
86f4d18
missing
hoangdv2429 Mar 19, 2025
825fecc
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
hoangdv2429 Mar 19, 2025
1c9bc0a
add changelog
hoangdv2429 Mar 19, 2025
e804514
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
Mar 19, 2025
ab257a1
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
Mar 19, 2025
d961824
address lint issues
hoangdv2429 Mar 20, 2025
2c28093
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
hoangdv2429 Mar 20, 2025
a9022e3
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
Mar 20, 2025
f75d4a2
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
Mar 20, 2025
e830d07
update cmt on mechanism
hoangdv2429 Mar 21, 2025
ded9c9a
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
hoangdv2429 Mar 21, 2025
280c2a3
test: add test and comments on SpendableCoins testcases
hoangdv2429 Mar 21, 2025
6f3d40f
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
hoangdv2429 Mar 22, 2025
ce7d6f0
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
Mar 24, 2025
e5df954
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
hoangdv2429 Mar 26, 2025
fe0862d
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
Mar 26, 2025
00b2355
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
hoangdv2429 Mar 27, 2025
cda9f34
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
Mar 27, 2025
8f7103b
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
Mar 28, 2025
f9c8f0f
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
hoangdv2429 Mar 29, 2025
c7b3797
Merge branch 'release/v0.53.x' into fix/negative_spendable_coins
hoangdv2429 Mar 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ Ref: https://keepachangelog.com/en/1.0.0/

### Bug Fixes

* [#24060](https://github.com/cosmos/cosmos-sdk/pull/24060) Fix handling of negative spendable balances.
* 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.
* 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`).
* The `SpendableCoin` keeper method now returns a zero coin if there's more locked than available (used to return a negative coin).
* (baseapp) [#24042](https://github.com/cosmos/cosmos-sdk/pull/24042) Fixed a data race inside BaseApp.getContext, found by end-to-end (e2e) tests.
* (client/server) [#24059](https://github.com/cosmos/cosmos-sdk/pull/24059) Consistently set viper prefix in client and server. It defaults for the binary name for both client and server.
* (client/keys) [#24041](https://github.com/cosmos/cosmos-sdk/pull/24041) `keys delete` won't terminate when a key is not found, but will log the error.
Expand Down
9 changes: 6 additions & 3 deletions tests/integration/bank/keeper/deterministic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type deterministicFixture struct {
}

func initDeterministicFixture(t *testing.T) *deterministicFixture {
t.Helper()
keys := storetypes.NewKVStoreKeys(authtypes.StoreKey, banktypes.StoreKey)
cdc := moduletestutil.MakeTestEncodingConfig(auth.AppModuleBasic{}, bank.AppModuleBasic{}).Codec

Expand Down Expand Up @@ -225,7 +226,7 @@ func TestGRPCQuerySpendableBalances(t *testing.T) {
assert.NilError(t, err)

req := banktypes.NewQuerySpendableBalancesRequest(addr1, nil)
testdata.DeterministicIterations(f.ctx, t, req, f.queryClient.SpendableBalances, 2032, false)
testdata.DeterministicIterations(f.ctx, t, req, f.queryClient.SpendableBalances, 1675, false)
}

func TestGRPCQueryTotalSupply(t *testing.T) {
Expand Down Expand Up @@ -311,7 +312,8 @@ func TestGRPCQueryParams(t *testing.T) {
DefaultSendEnabled: rapid.Bool().Draw(rt, "send"),
}

f.bankKeeper.SetParams(f.ctx, params)
err := f.bankKeeper.SetParams(f.ctx, params)
assert.NilError(t, err)

req := &banktypes.QueryParamsRequest{}
testdata.DeterministicIterations(f.ctx, t, req, f.queryClient.Params, 0, true)
Expand All @@ -327,7 +329,8 @@ func TestGRPCQueryParams(t *testing.T) {
DefaultSendEnabled: false,
}

f.bankKeeper.SetParams(f.ctx, params)
err := f.bankKeeper.SetParams(f.ctx, params)
assert.NilError(t, err)

req := &banktypes.QueryParamsRequest{}
testdata.DeterministicIterations(f.ctx, t, req, f.queryClient.Params, 1003, false)
Expand Down
25 changes: 14 additions & 11 deletions x/bank/keeper/grpc_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,22 +95,25 @@ func (k BaseKeeper) SpendableBalances(ctx context.Context, req *types.QuerySpend
sdkCtx := sdk.UnwrapSDKContext(ctx)

zeroAmt := math.ZeroInt()

balances, pageRes, err := query.CollectionPaginate(ctx, k.Balances, req.Pagination, func(key collections.Pair[sdk.AccAddress, string], _ math.Int) (coin sdk.Coin, err error) {
return sdk.NewCoin(key.K2(), zeroAmt), nil
allLocked := k.LockedCoins(sdkCtx, addr)

balances, pageRes, err := query.CollectionPaginate(ctx, k.Balances, req.Pagination, func(key collections.Pair[sdk.AccAddress, string], balanceAmt math.Int) (sdk.Coin, error) {
denom := key.K2()
coin := sdk.NewCoin(denom, zeroAmt)
lockedAmt := allLocked.AmountOf(denom)
switch {
case !lockedAmt.IsPositive():
coin.Amount = balanceAmt
case lockedAmt.LT(balanceAmt):
coin.Amount = balanceAmt.Sub(lockedAmt)
}
return coin, nil
}, query.WithCollectionPaginationPairPrefix[sdk.AccAddress, string](addr))
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "paginate: %v", err)
}

result := sdk.NewCoins()
spendable := k.SpendableCoins(sdkCtx, addr)

for _, c := range balances {
result = append(result, sdk.NewCoin(c.Denom, spendable.AmountOf(c.Denom)))
}

return &types.QuerySpendableBalancesResponse{Balances: result, Pagination: pageRes}, nil
return &types.QuerySpendableBalancesResponse{Balances: balances, Pagination: pageRes}, nil
}

// SpendableBalanceByDenom implements a gRPC query handler for retrieving an account's
Expand Down
53 changes: 53 additions & 0 deletions x/bank/keeper/keeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1473,12 +1473,27 @@ func (suite *KeeperTestSuite) TestMsgMultiSendEvents() {
require.Equal(abci.Event(event3), events[29])
}

// TestSpendableCoins verifies the behavior of SpendableCoins under various scenarios:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aljo242 I also added document on what testcases we have. I hope it cover every paths you need.

// 1. Normal non-vesting account:
// - The account has no locked coins so that the full balance is spendable.
//
// 2. Vesting account with partial unlock:
// - After advancing time to mid-vesting, part of the total coins are locked.
// The spendable coins are calculated as total minus locked coins.
//
// 3. Vesting account with mixed locked and unlocked coins:
// - Some denominations are over-locked (resulting in a negative subtraction) so that those
// denominations yield a zero spendable amount, while others return the positive unlocked value.
//
// 4. Vesting account with all coins locked:
// - When vesting has not started (all coins locked), the spendable coins are zero.
func (suite *KeeperTestSuite) TestSpendableCoins() {
ctx := sdk.UnwrapSDKContext(suite.ctx)
require := suite.Require()
now := cmttime.Now()
endTime := now.Add(24 * time.Hour)

// Case 1: Normal non-vesting account with full spendable balance.
origCoins := sdk.NewCoins(sdk.NewInt64Coin("stake", 100))
lockedCoins := sdk.NewCoins(sdk.NewInt64Coin("stake", 50))

Expand All @@ -1499,12 +1514,50 @@ func (suite *KeeperTestSuite) TestSpendableCoins() {
suite.mockSpendableCoins(ctx, acc1)
require.Equal(origCoins[0], suite.bankKeeper.SpendableCoin(ctx, accAddrs[1], "stake"))

// Case 2: Vesting account with partial unlock.
ctx = ctx.WithBlockTime(now.Add(12 * time.Hour))
suite.mockSpendableCoins(ctx, vacc)
require.Equal(origCoins.Sub(lockedCoins...), suite.bankKeeper.SpendableCoins(ctx, accAddrs[0]))

suite.mockSpendableCoins(ctx, vacc)
require.Equal(origCoins.Sub(lockedCoins...)[0], suite.bankKeeper.SpendableCoin(ctx, accAddrs[0], "stake"))

// Case 3: Vesting account with mixed locked and unlocked coins.
acc2 := authtypes.NewBaseAccountWithAddress(accAddrs[2])
lockedCoins2 := sdk.NewCoins(sdk.NewInt64Coin("stake", 50), sdk.NewInt64Coin("tarp", 40), sdk.NewInt64Coin("rope", 30))
balanceCoins2 := sdk.NewCoins(sdk.NewInt64Coin("stake", 49), sdk.NewInt64Coin("tarp", 40), sdk.NewInt64Coin("rope", 31), sdk.NewInt64Coin("pole", 20))
expCoins2 := sdk.NewCoins(sdk.NewInt64Coin("rope", 1), sdk.NewInt64Coin("pole", 20))
vacc2, err := vesting.NewPermanentLockedAccount(acc2, lockedCoins2)
suite.Require().NoError(err)

// Go back to the suite's context since mockFundAccount uses that; FundAccount would fail for bad mocking otherwise.
ctx = sdk.UnwrapSDKContext(suite.ctx)
suite.mockFundAccount(accAddrs[2])
require.NoError(banktestutil.FundAccount(ctx, suite.bankKeeper, accAddrs[2], balanceCoins2))
suite.mockSpendableCoins(ctx, vacc2)
require.Equal(expCoins2, suite.bankKeeper.SpendableCoins(ctx, accAddrs[2]))
suite.mockSpendableCoins(ctx, vacc2)
require.Equal(sdk.NewInt64Coin("stake", 0), suite.bankKeeper.SpendableCoin(ctx, accAddrs[2], "stake"))
suite.mockSpendableCoins(ctx, vacc2)
require.Equal(sdk.NewInt64Coin("tarp", 0), suite.bankKeeper.SpendableCoin(ctx, accAddrs[2], "tarp"))
suite.mockSpendableCoins(ctx, vacc2)
require.Equal(sdk.NewInt64Coin("rope", 1), suite.bankKeeper.SpendableCoin(ctx, accAddrs[2], "rope"))
suite.mockSpendableCoins(ctx, vacc2)
require.Equal(sdk.NewInt64Coin("pole", 20), suite.bankKeeper.SpendableCoin(ctx, accAddrs[2], "pole"))

// Case 4: All coins locked.
// Create a vesting account that has not started vesting yet (all coins locked).
acc3 := authtypes.NewBaseAccountWithAddress(accAddrs[3])
// Set vesting start to one hour in the future so that all coins are locked.
futureStart := now.Add(1 * time.Hour)
futureEnd := futureStart.Add(24 * time.Hour)
vaccAllLocked, err := vesting.NewContinuousVestingAccount(acc3, sdk.NewCoins(sdk.NewInt64Coin("stake", 50)), futureStart.Unix(), futureEnd.Unix())
suite.Require().NoError(err)
suite.mockFundAccount(accAddrs[3])
require.NoError(banktestutil.FundAccount(ctx, suite.bankKeeper, accAddrs[3], sdk.NewCoins(sdk.NewInt64Coin("stake", 50))))
// Since vesting has not started, all coins remain locked and spendable coins should be zero.
suite.mockSpendableCoins(ctx, vaccAllLocked)
require.Equal(sdk.NewCoins(), suite.bankKeeper.SpendableCoins(ctx, accAddrs[3]))
}

func (suite *KeeperTestSuite) TestVestingAccountSend() {
Expand Down
54 changes: 34 additions & 20 deletions x/bank/keeper/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,34 @@ func (k BaseViewKeeper) LockedCoins(ctx context.Context, addr sdk.AccAddress) sd
return sdk.NewCoins()
}

// SpendableCoins returns the total balances of spendable coins for an account
// by address. If the account has no spendable coins, an empty Coins slice is
// returned.
// SpendableCoins returns the total balances of spendable coins (i.e. unlocked) for an account by address.
// It works as follows:
// 1. It retrieves the total balance (all coins) for the account.
// 2. It gets the set of locked coins (coins that are not spendable, e.g. due to vesting).
// 3. If there are no locked coins, it returns the total balance as all coins are spendable.
// 4. Otherwise, it attempts to subtract the locked coins from the total balance using SafeSub.
// - If SafeSub does not produce any negative values, the result (unlocked coins) is returned.
// - If subtraction results in any negative values for a denomination (i.e. there are more locked coins than available in that denom),
// then for that denomination the spendable amount is considered to be zero. In this case, the function iterates over each coin in the
// unlocked result and includes only those coins with a positive amount in the final spendable coins set.
func (k BaseViewKeeper) SpendableCoins(ctx context.Context, addr sdk.AccAddress) sdk.Coins {
spendable, _ := k.spendableCoins(ctx, addr)
total := k.GetAllBalances(ctx, addr)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a comment to the function that describes exactly how this works?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i.e.

What we do when we have:

  • no locked coins
  • more locked than in balances
  • some locked, some unlocked

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated with specific flow

allLocked := k.LockedCoins(ctx, addr)
if allLocked.IsZero() {
return total
}

unlocked, hasNeg := total.SafeSub(allLocked...)
if !hasNeg {
return unlocked
}

spendable := sdk.Coins{}
for _, coin := range unlocked {
if coin.IsPositive() {
spendable = append(spendable, coin)
}
}
return spendable
}

Expand All @@ -203,23 +226,14 @@ func (k BaseViewKeeper) SpendableCoins(ctx context.Context, addr sdk.AccAddress)
// is returned.
func (k BaseViewKeeper) SpendableCoin(ctx context.Context, addr sdk.AccAddress, denom string) sdk.Coin {
balance := k.GetBalance(ctx, addr, denom)
locked := k.LockedCoins(ctx, addr)
return balance.SubAmount(locked.AmountOf(denom))
}

// spendableCoins returns the coins the given address can spend alongside the total amount of coins it holds.
// It exists for gas efficiency, in order to avoid to have to get balance multiple times.
func (k BaseViewKeeper) spendableCoins(ctx context.Context, addr sdk.AccAddress) (spendable, total sdk.Coins) {
total = k.GetAllBalances(ctx, addr)
locked := k.LockedCoins(ctx, addr)

spendable, hasNeg := total.SafeSub(locked...)
if hasNeg {
spendable = sdk.NewCoins()
return
lockedAmt := k.LockedCoins(ctx, addr).AmountOf(denom)
if !lockedAmt.IsPositive() {
return balance
}

return
if lockedAmt.LT(balance.Amount) {
return balance.SubAmount(lockedAmt)
}
return sdk.NewCoin(denom, math.ZeroInt())
}

// ValidateBalance validates all balances for a given account address returning
Expand Down
Loading