Skip to content

#[BUG] Forced Minimum 1 Coin Causes Reward Over-Distribution #265

@ngapaxs

Description

@ngapaxs

Bug Description

Found an issue in the rewards module where it forces at least 1 coin to be released per block, even when the math says it should be less. This breaks long-term reward schedules.

Look at x/rewards/types/reward.go line 42:

// Make sure at least one coin will be distributed if there is still a distribution to happen
amountToRelease = math.MaxInt(amountToRelease, math.NewInt(1))

The problem: when you have a long vesting schedule (like 1 year), each block should only release a tiny fraction of a coin. But this line forces it to always release at least 1 coin, so the pool drains way faster than it should.

How to reproduce

I tested with a simple schedule:

  • Total: 1000 tokens
  • Duration: 1,000,000 seconds (about 11.5 days)
  • Block time: 6 seconds

Math says each block should release: (6/1,000,000) * 1000 = 0.006 tokens

But the code forces minimum 1 token per block.

So after 100 blocks (600 seconds):

  • Expected: 0.6 tokens released
  • Actual: 100 tokens released

That's 166x faster than it should be!

What should happen vs what actually happens

The calculation works like this:

timeElapsed = 6 seconds
totalDuration = 1,000,000 seconds
proportion = 6/1,000,000 = 0.000006
amountToRelease = 1000 * 0.000006 = 0.006

This truncates to 0 (because coins are integers), then the MaxInt line forces it to 1.

Result: pool drains in 1000 blocks instead of 166,666 blocks.

Environment

Tested on Ubuntu 22.04 with Go 1.23.5

Impact

This breaks any long-term reward program. Example with 1 year vesting:

  • 1M tokens over 365 days
  • Block time 6 seconds
  • Should release ~0.19 tokens per block
  • Actually releases 1 token per block
  • Finishes in 70 days instead of 365 days

The pool drains 5x faster than planned, completely breaking the tokenomics.

Test Case

func TestRewardOverDistribution(t *testing.T) {
	totalAmount := sdk.NewCoin("akii", math.NewInt(1000))
	startTime := time.Now()
	endTime := startTime.Add(1000000 * time.Second)
	
	schedule := types.ReleaseSchedule{
		TotalAmount:     totalAmount,
		ReleasedAmount:  sdk.NewCoin("akii", math.ZeroInt()),
		LastReleaseTime: startTime,
		EndTime:         endTime,
		Active:          true,
	}

	totalReleased := math.ZeroInt()
	currentTime := startTime
	
	for i := 0; i < 100; i++ {
		currentTime = currentTime.Add(6 * time.Second)
		reward, err := types.CalculateReward(currentTime, schedule)
		require.NoError(t, err)
		
		totalReleased = totalReleased.Add(reward.Amount)
		schedule.ReleasedAmount = sdk.NewCoin("akii", totalReleased)
		schedule.LastReleaseTime = currentTime
	}

	// After 100 blocks (600 seconds), should release ~0.6 tokens
	// But actually releases 100 tokens
	require.Equal(t, math.NewInt(100), totalReleased)
}

Test Results

$ cd /root/kiichain && /root/kiichain/go/bin/go test -v ./x/rewards/types -run TestRewardOverDistribution

=== RUN   TestRewardOverDistribution
    reward_over_distribution_test.go:51: Total released after 100 blocks: 100
    reward_over_distribution_test.go:52: Expected release: ~0.6 tokens
    reward_over_distribution_test.go:53: Actual release: 100 tokens
--- PASS: TestRewardOverDistribution (0.00s)
PASS

Declaration

  • Not a duplicate
  • Tested and confirmed
  • Understand false reports are rejected

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions