Assessment reports>Nibiru>High findings>Iterating over maps is nondeterministic
Category: Coding Mistakes

Iterating over maps is nondeterministic

High Severity
Low Impact
Low Likelihood

Description

It is vitally important that any calculations done by the chain are deterministic and can be reproduced by every validator so that the state of the network can be agreed upon. One source of nondeterminism in Go is iterating over maps: the specification states, "The iteration order over maps is not specified and is not guaranteed to be the same from one iteration to the next" (https://go.dev/ref/spec#RangeClause).

The removeInvalidBallots and countVotesAndUpdateExchangeRates functions are both called from an EndBlocker and iterate over a map:

func (k Keeper) removeInvalidBallots(
	ctx sdk.Context,
	pairBallotsMap map[asset.Pair]types.ExchangeRateBallots,
) (map[asset.Pair]types.ExchangeRateBallots, set.Set[asset.Pair]) {
	whitelistedPairs := set.New(k.GetWhitelistedPairs(ctx)...)

	totalBondedPower := sdk.TokensToConsensusPower(k.StakingKeeper.TotalBondedTokens(ctx), k.StakingKeeper.PowerReduction(ctx))
	thresholdVotingPower := k.VoteThreshold(ctx).MulInt64(totalBondedPower).RoundInt()
	minVoters := k.MinVoters(ctx)

	for pair, ballots := range pairBallotsMap {
		// If pair is not whitelisted, or the ballot for it has failed, then skip
		// and remove it from pairBallotsMap for iteration efficiency
		if _, exists := whitelistedPairs[pair]; !exists {
			delete(pairBallotsMap, pair)
			continue
		}

		// If the ballot is not passed, remove it from the whitelistedPairs set
		// to prevent slashing validators who did valid vote.
		if !isPassingVoteThreshold(ballots, thresholdVotingPower, minVoters) {
			delete(whitelistedPairs, pair)
			delete(pairBallotsMap, pair)
			continue
		}
	}

	return pairBallotsMap, whitelistedPairs
}

func (k Keeper) countVotesAndUpdateExchangeRates(
	ctx sdk.Context,
	pairBallotsMap map[asset.Pair]types.ExchangeRateBallots,
	validatorPerformances types.ValidatorPerformances,
) {
	rewardBand := k.RewardBand(ctx)

	for pair, ballots := range pairBallotsMap {
		exchangeRate := Tally(ballots, rewardBand, validatorPerformances)

		k.SetPrice(ctx, pair, exchangeRate)

		ctx.EventManager().EmitEvent(
			sdk.NewEvent(types.EventTypeExchangeRateUpdate,
				sdk.NewAttribute(types.AttributeKeyPair, pair.String()),
				sdk.NewAttribute(types.AttributeKeyExchangeRate, exchangeRate.String()),
			),
		)
	}
}

Impact

Currently, the operations that are performed in k.SetPrice end up being the same regardless of the order it is called, so it is unlikely to cause a chain halt. However, a simple unrelated change to a keeper in the future could cause this issue to occur as the order that the pairs are processed can be different.

Recommendations

Instead of iterating over the map, the function should iterate over a sorted list of keys.

// countVotesAndUpdateExchangeRates processes the votes and updates the ExchangeRates based on the results.
func (k Keeper) countVotesAndUpdateExchangeRates(
	ctx sdk.Context,
	pairBallotsMap map[asset.Pair]types.ExchangeRateBallots,
	validatorPerformances types.ValidatorPerformances,
) {
	rewardBand := k.RewardBand(ctx)

	keys := make([]string, 0, len(pairBallotsMap))
	for key := range pairBallotsMap {
		keys = append(keys, key.String())
	}
	sort.Strings(keys)

	for _, key := range keys {
		pair := asset.MustNewPair(key)
		ballots := pairBallotsMap[pair]
		exchangeRate := Tally(ballots, rewardBand, validatorPerformances)

		k.SetPrice(ctx, pair, exchangeRate)

		ctx.EventManager().EmitEvent(
			sdk.NewEvent(types.EventTypeExchangeRateUpdate,
				sdk.NewAttribute(types.AttributeKeyPair, pair.String()),
				sdk.NewAttribute(types.AttributeKeyExchangeRate, exchangeRate.String()),
			),
		)
	}
}

Remediation

This issue has been acknowledged by Nibiru, and a fix was implemented in commit 3482a22d.

Zellic © 2024Back to top ↑