Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 43 additions & 0 deletions adr/adr1011 - dispute round fees.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# ADR 1011: Dispute round fees

## Authors

@akrem

## Changelog

- 2026-06-10: initial version

## Context

A dispute can go through more than one round. Round 1 is the main dispute. If a round does not reach quorum someone can push it to another round by paying a fee, and that fee doubles every round. The chain caps this at 5 rounds.

This ADR is about what the fee is for each round and what happens to it.

Round 1's fee is refundable. You pay the dispute fee, which is the same as the slash amount. If the dispute resolves support you get your fee back minus a small burn and you also get the reporter's slashed tokens. If it resolves invalid you just get your fee back minus the burn. If it resolves against you the reporter gets your fee. So the round 1 fee comes back to you unless you lose the dispute.

Rounds 2 and up are different. The fee you pay to start another round is not refundable to anyone. It is a burn. The whole fee gets consumed, half of it is actually burned and half goes to the voters of the dispute (if no users or reporters voted in any round, the voter half is burned as well). The only thing that is ever refundable is the round 1 fee.

The fee for each extra round starts at 5% of the slash amount and doubles each round. So round 2 is 10% of the slash, round 3 is 20%, round 4 is 40%, round 5 is 80%, capped at 100%. It gets expensive fast on purpose.

Why it works this way:

If a dispute is extended to multiple rounds, the escalating fee does two things:

1. The doubling fee keeps anyone from dragging a dispute out forever. Every extra round costs a lot more than the last one.
2. Half of the round fee goes to the voters to incentivize them to vote on the extra rounds. The fee grows as the rounds go up, so the reward for voting grows with it. Burning all of it instead would just destroy the tokens.

The round 1 fee stays separate from all of this. It is the part that is actually at risk between the disputer and the reporter, and the final round decides what happens to it. The extra round fees are just the cost of asking for another vote.


## Issues / Notes on Implementation

The fee schedule for reference, with s as the slash amount (5% is s/20):

- Round 1: pay s. This is the refundable fee. 5% is consumed (half burned, half reserved as the voter reward; all of it burned if no users or reporters voted), 95% is refundable.
- Round 2: pay s/10 (10% of s). Fully consumed.
- Round 3: pay s/5 (20%). Fully consumed.
- Round 4: pay 2s/5 (40%). Fully consumed.
- Round 5: pay 4s/5 (80%). Fully consumed.

Disputes are capped at 5 rounds.
6 changes: 3 additions & 3 deletions tests/integration/dispute_keeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1154,7 +1154,7 @@ func (s *IntegrationTestSuite) TestDisputeFiveRounds5thRdNewPayer() {
s.NoError(err)
s.Equal(types.Resolved, dispute.DisputeStatus)
fmt.Println("DISPUTE FEE TOTAL", dispute.FeeTotal.String())
expectedClaimAmount := dispute.FeeTotal.MulRaw(95).QuoRaw(100)
expectedClaimAmount := dispute.DisputeFee.MulRaw(95).QuoRaw(100)
fmt.Println("EXPECTED CLAIM AMOUNT", expectedClaimAmount)

_, err = s.Setup.App.BeginBlocker(s.Setup.Ctx)
Expand Down Expand Up @@ -1535,7 +1535,7 @@ func (s *IntegrationTestSuite) TestDisputeFiveRounds1Payer() {
s.NoError(err)
s.Equal(types.Resolved, dispute.DisputeStatus)
fmt.Println("DISPUTE FEE TOTAL", dispute.FeeTotal.String())
expectedClaimAmount := dispute.FeeTotal.MulRaw(95).QuoRaw(100)
expectedClaimAmount := dispute.SlashAmount.MulRaw(95).QuoRaw(100)
fmt.Println("EXPECTED CLAIM AMOUNT", expectedClaimAmount)

_, err = s.Setup.App.BeginBlocker(s.Setup.Ctx)
Expand Down Expand Up @@ -1945,7 +1945,7 @@ func (s *IntegrationTestSuite) TestDisputeFiveRoundsTwoFeePayers() {
s.NoError(err)
s.Equal(types.Resolved, dispute.DisputeStatus)
fmt.Println("DISPUTE FEE TOTAL", dispute.FeeTotal.String())
expectedClaimAmountTotal := dispute.FeeTotal.MulRaw(95).QuoRaw(100)
expectedClaimAmountTotal := dispute.DisputeFee.MulRaw(95).QuoRaw(100)
fmt.Println("EXPECTED CLAIM AMOUNT TOTAL", expectedClaimAmountTotal)

_, err = s.Setup.App.BeginBlocker(s.Setup.Ctx)
Expand Down
625 changes: 625 additions & 0 deletions tests/integration/dispute_multiround_support_test.go

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions x/dispute/keeper/claim_reward.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ func (k Keeper) CalculateReward(ctx sdk.Context, addr sdk.AccAddress, id uint64)
globalUserPower := math.ZeroInt()

for _, pastId := range dispute.PrevDisputeIds {
// Get global vote counts for the past dispute. A missing row means that
// round had no user/reporter votes to reward.
pastVoteCounts, err := k.VoteCountsByGroup.Get(ctx, pastId)
if err != nil {
if errors.Is(err, collections.ErrNotFound) {
continue
}
return math.Int{}, err
}

pastVoterInfo, err := k.Voter.Get(ctx, collections.Join(pastId, addr.Bytes()))
if err == nil {
// Voter info exists for this past dispute
Expand All @@ -88,13 +98,10 @@ func (k Keeper) CalculateReward(ctx sdk.Context, addr sdk.AccAddress, id uint64)
return math.Int{}, err
}
addrUserPower = addrUserPower.Add(userTips)
}

// Get global vote counts for the past dispute
pastVoteCounts, err := k.VoteCountsByGroup.Get(ctx, pastId)
if err != nil {
} else if !errors.Is(err, collections.ErrNotFound) {
return math.Int{}, err
}

// Add up the global power for each group
globalReporterPower = globalReporterPower.Add(math.NewIntFromUint64(pastVoteCounts.Reporters.Support)).
Add(math.NewIntFromUint64(pastVoteCounts.Reporters.Against)).Add(math.NewIntFromUint64(pastVoteCounts.Reporters.Invalid))
Expand Down
8 changes: 5 additions & 3 deletions x/dispute/keeper/dispute.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,9 @@ func (k Keeper) AddDisputeRound(ctx sdk.Context, sender sdk.AccAddress, dispute
msg.Fee.Amount = roundFee
}

// Pay the dispute fee
if err := k.PayDisputeFee(ctx, sender, msg.Fee, msg.PayFromBond, dispute.HashId, true); err != nil {
// Pay the dispute fee. Later-round fees are fully consumed (never refunded), so they
// must not be tracked as refundable first-round stake.
if err := k.PayDisputeFee(ctx, sender, msg.Fee, msg.PayFromBond, dispute.HashId, false); err != nil {
return err
}

Expand All @@ -268,7 +269,8 @@ func (k Keeper) AddDisputeRound(ctx sdk.Context, sender sdk.AccAddress, dispute
}

prevDisputeId := dispute.DisputeId
dispute.BurnAmount = dispute.BurnAmount.Add(fivePercent) // burnAmt = 5 % of fee total
dispute.BurnAmount = dispute.BurnAmount.Add(roundFee)
// FeeTotal is informationl only, tracks total fees paid across rounds.
dispute.FeeTotal = dispute.FeeTotal.Add(msg.Fee.Amount)
disputeId := k.NextDisputeId(ctx)
dispute.DisputeId = disputeId
Expand Down
36 changes: 21 additions & 15 deletions x/dispute/keeper/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
layertypes "github.com/tellor-io/layer/types"
"github.com/tellor-io/layer/x/dispute/types"

"cosmossdk.io/collections"
"cosmossdk.io/math"

sdk "github.com/cosmos/cosmos-sdk/types"
Expand Down Expand Up @@ -48,17 +49,20 @@ func (k Keeper) ExecuteVote(ctx context.Context, id uint64) error {
return errors.New("vote already executed")
}

// the burnAmount starts at %5 of disputeFee, half of which is burned and the other half is distributed to the voters
// the burnAmount is round 1's 5% of the dispute fee plus all later-round fees, half
// of which is burned and the other half is distributed to the voters
disputeBurnAmountDec := math.LegacyNewDecFromInt(dispute.BurnAmount)
halfBurnAmountDec := disputeBurnAmountDec.Quo(math.LegacyNewDec(2))
halfBurnAmount := halfBurnAmountDec.TruncateInt()
voterReward := halfBurnAmount
totalVoterPower, err := k.GetSumOfAllGroupVotesAllRounds(ctx, id)
// only user and reporter votes can claim through CalculateReward; team votes decide
// outcomes but earn nothing, so they must not cause a voter reward to be reserved
totalVoterPower, err := k.GetSumOfUserAndReporterVotesAllRounds(ctx, id)
if err != nil {
return err
}
if totalVoterPower.IsZero() {
// if no voters, burn the entire burnAmount
// if no claim-eligible voters, burn the entire burnAmount
halfBurnAmount = dispute.BurnAmount
// non voters get nothing
voterReward = math.ZeroInt()
Expand Down Expand Up @@ -153,8 +157,8 @@ func (k Keeper) RefundFailedDisputeFee(ctx context.Context, feePayer sdk.AccAddr
return k.ReturnFeetoStake(ctx, hashId, fee)
}

func (k Keeper) RefundDisputeFee(ctx context.Context, feePayer sdk.AccAddress, payerInfo types.PayerInfo, disputeFeeTotal math.Int, hashId []byte, slashAmt math.Int) (math.Int, error) {
amtFixed6, remainder := CalculateRefundAmount(payerInfo.Amount, slashAmt, disputeFeeTotal)
func (k Keeper) RefundDisputeFee(ctx context.Context, feePayer sdk.AccAddress, payerInfo types.PayerInfo, totalFeeRd1 math.Int, hashId []byte) (math.Int, error) {
amtFixed6, remainder := CalculateRefundAmount(payerInfo.Amount, totalFeeRd1)

coins := sdk.NewCoins(sdk.NewCoin(layertypes.BondDenom, amtFixed6))
if !payerInfo.FromBond {
Expand All @@ -173,46 +177,48 @@ func (k Keeper) RewardReporterBondToFeePayers(ctx context.Context, feePayer sdk.
return remainder, k.bankKeeper.SendCoinsFromModuleToModule(ctx, types.ModuleName, stakingtypes.BondedPoolName, sdk.NewCoins(sdk.NewCoin(layertypes.BondDenom, amtFixed6)))
}

func (k Keeper) GetSumOfAllGroupVotesAllRounds(ctx context.Context, id uint64) (math.Int, error) {
// GetSumOfUserAndReporterVotesAllRounds sums claim-eligible (user and reporter) vote
// power across all rounds of a dispute. Team votes are excluded because they cannot
// claim voter rewards.
func (k Keeper) GetSumOfUserAndReporterVotesAllRounds(ctx context.Context, id uint64) (math.Int, error) {
dispute, err := k.Disputes.Get(ctx, id)
if err != nil {
return math.Int{}, err
}

sumUsers := uint64(0)
sumReporters := uint64(0)
sumTeam := uint64(0)

// process vote counts function
processVoteCounts := func(voteCounts types.StakeholderVoteCounts) {
sumUsers += voteCounts.Users.Support + voteCounts.Users.Against + voteCounts.Users.Invalid
sumReporters += voteCounts.Reporters.Support + voteCounts.Reporters.Against + voteCounts.Reporters.Invalid
sumTeam += voteCounts.Team.Support + voteCounts.Team.Against + voteCounts.Team.Invalid
}

// process current dispute
voteCounts, err := k.VoteCountsByGroup.Get(ctx, id)
if err != nil {
return math.ZeroInt(), nil
if !errors.Is(err, collections.ErrNotFound) {
return math.Int{}, err
}
voteCounts = types.StakeholderVoteCounts{}
}
processVoteCounts(voteCounts)

// process previous disputes
for _, roundId := range dispute.PrevDisputeIds {
voteCounts, err := k.VoteCountsByGroup.Get(ctx, roundId)
if err != nil {
voteCounts = types.StakeholderVoteCounts{
Users: types.VoteCounts{Support: 0, Against: 0, Invalid: 0},
Reporters: types.VoteCounts{Support: 0, Against: 0, Invalid: 0},
Team: types.VoteCounts{Support: 0, Against: 0, Invalid: 0},
if !errors.Is(err, collections.ErrNotFound) {
return math.Int{}, err
}
voteCounts = types.StakeholderVoteCounts{}
}
processVoteCounts(voteCounts)
}

totalSum := math.NewInt(int64(sumUsers)).
Add(math.NewInt(int64(sumReporters))).
Add(math.NewInt(int64(sumTeam)))
Add(math.NewInt(int64(sumReporters)))

return totalSum, nil
}
36 changes: 16 additions & 20 deletions x/dispute/keeper/execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@ func (k *KeeperTestSuite) TestRefundDisputeFee() {

k.reporterKeeper.On("FeeRefund", k.ctx, []byte("hash"), math.NewInt(760)).Return(nil)
k.bankKeeper.On("SendCoinsFromModuleToModule", k.ctx, types.ModuleName, "bonded_tokens_pool", sdk.NewCoins(sdk.NewCoin("loya", math.NewInt(760)))).Return(nil)
dust, err := k.disputeKeeper.RefundDisputeFee(k.ctx, feepayer1, feePayers[0], math.NewInt(1000), []byte("hash"), math.NewInt(1000))
dust, err := k.disputeKeeper.RefundDisputeFee(k.ctx, feepayer1, feePayers[0], math.NewInt(1000), []byte("hash"))
k.NoError(err)
k.True(math.ZeroInt().Equal(dust))

k.bankKeeper.On("SendCoinsFromModuleToAccount", k.ctx, types.ModuleName, feepayer2, sdk.NewCoins(sdk.NewCoin("loya", math.NewInt(190)))).Return(nil)
dust, err = k.disputeKeeper.RefundDisputeFee(k.ctx, feepayer2, feePayers[1], math.NewInt(1000), []byte("hash"), math.NewInt(1000))
dust, err = k.disputeKeeper.RefundDisputeFee(k.ctx, feepayer2, feePayers[1], math.NewInt(1000), []byte("hash"))
k.NoError(err)
k.True(math.ZeroInt().Equal(dust))
}
Expand Down Expand Up @@ -155,23 +155,22 @@ func (k *KeeperTestSuite) TestRewardReporterBondToFeePayers() {
k.Equal(shareFixed12.Mod(layertypes.PowerReduction), dust)
}

func (k *KeeperTestSuite) TestGetSumOfAllGroupVotesAllRounds() {
func (k *KeeperTestSuite) TestGetSumOfUserAndReporterVotesAllRounds() {
k.ctx = k.ctx.WithBlockTime(time.Now())
dispute := k.dispute(k.ctx)
k.NoError(k.disputeKeeper.Disputes.Set(k.ctx, dispute.DisputeId, dispute))

// set vote counts for current dispute
// set vote counts for current dispute; team votes are set but must not be counted
currentVoteCounts := types.StakeholderVoteCounts{
Users: types.VoteCounts{Support: 10, Against: 5, Invalid: 2}, // 17
Reporters: types.VoteCounts{Support: 8, Against: 3, Invalid: 1}, // 12
// Tokenholders: types.VoteCounts{Support: 15, Against: 7, Invalid: 3}, // 25
Team: types.VoteCounts{Support: 5, Against: 2, Invalid: 1}, // 8 total=37
Team: types.VoteCounts{Support: 5, Against: 2, Invalid: 1}, // excluded, total=29
}
k.NoError(k.disputeKeeper.VoteCountsByGroup.Set(k.ctx, dispute.DisputeId, currentVoteCounts))

// test no previous disputes
expectedTotalSum := math.NewInt(37)
totalSum, err := k.disputeKeeper.GetSumOfAllGroupVotesAllRounds(k.ctx, dispute.DisputeId)
expectedTotalSum := math.NewInt(29)
totalSum, err := k.disputeKeeper.GetSumOfUserAndReporterVotesAllRounds(k.ctx, dispute.DisputeId)
k.NoError(err)
k.True(expectedTotalSum.Equal(totalSum))

Expand All @@ -181,20 +180,17 @@ func (k *KeeperTestSuite) TestGetSumOfAllGroupVotesAllRounds() {
{
Users: types.VoteCounts{Support: 5, Against: 3, Invalid: 1}, // 9
Reporters: types.VoteCounts{Support: 4, Against: 2, Invalid: 0}, // 6
// Tokenholders: types.VoteCounts{Support: 8, Against: 4, Invalid: 2}, // 14
Team: types.VoteCounts{Support: 3, Against: 1, Invalid: 0}, // 4 total=19
Team: types.VoteCounts{Support: 3, Against: 1, Invalid: 0}, // excluded, total=15
},
{
Users: types.VoteCounts{Support: 7, Against: 4, Invalid: 2}, // 13
Reporters: types.VoteCounts{Support: 6, Against: 3, Invalid: 1}, // 10
// Tokenholders: types.VoteCounts{Support: 10, Against: 5, Invalid: 2}, // 17
Team: types.VoteCounts{Support: 4, Against: 2, Invalid: 1}, // 7 total=30
Team: types.VoteCounts{Support: 4, Against: 2, Invalid: 1}, // excluded, total=23
},
{
Users: types.VoteCounts{Support: 3, Against: 2, Invalid: 0}, // 5
Reporters: types.VoteCounts{Support: 2, Against: 1, Invalid: 0}, // 3
// Tokenholders: types.VoteCounts{Support: 5, Against: 3, Invalid: 1}, // 9
Team: types.VoteCounts{Support: 2, Against: 1, Invalid: 0}, // 3 total=11
Team: types.VoteCounts{Support: 2, Against: 1, Invalid: 0}, // excluded, total=8
},
}

Expand All @@ -205,15 +201,15 @@ func (k *KeeperTestSuite) TestGetSumOfAllGroupVotesAllRounds() {

k.NoError(k.disputeKeeper.Disputes.Set(k.ctx, dispute.DisputeId, dispute))

// Calculate the expected total sum
// Calculate the expected total sum (team votes excluded)
expectedTotalSum = math.NewInt(0).
Add(math.NewInt(int64(17 + 12 + 8))). // Current dispute
Add(math.NewInt(int64(9 + 6 + 4))). // Previous dispute 1
Add(math.NewInt(int64(13 + 10 + 7))). // Previous dispute 2
Add(math.NewInt(int64(5 + 3 + 3))) // Previous dispute 3
Add(math.NewInt(int64(17 + 12))). // Current dispute
Add(math.NewInt(int64(9 + 6))). // Previous dispute 1
Add(math.NewInt(int64(13 + 10))). // Previous dispute 2
Add(math.NewInt(int64(5 + 3))) // Previous dispute 3

// Call the function and check the result
totalSum, err = k.disputeKeeper.GetSumOfAllGroupVotesAllRounds(k.ctx, dispute.DisputeId)
totalSum, err = k.disputeKeeper.GetSumOfUserAndReporterVotesAllRounds(k.ctx, dispute.DisputeId)
k.NoError(err)
k.True(expectedTotalSum.Equal(totalSum))
}
12 changes: 6 additions & 6 deletions x/dispute/keeper/math_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@ import (

// CalculateRefundAmount calculates the amount of the fee to be refunded to the payer
// returns the amount to be refunded (amtFixed6) and the remainder (dust)
func CalculateRefundAmount(payerFee, totalFeeRd1, disputeFeeTotal math.Int) (math.Int, math.Int) {
func CalculateRefundAmount(payerFee, totalFeeRd1 math.Int) (math.Int, math.Int) {
payerFeeDec := payerFee.ToLegacyDec()
totalFeeRd1Dec := math.LegacyNewDecFromInt(totalFeeRd1)

// fivePercent = disputeFeeTotal / 20
fivePercentDec := disputeFeeTotal.ToLegacyDec().Quo(math.LegacyNewDec(20))
// fivePercent = totalFeeRd1 / 20
fivePercentDec := totalFeeRd1.ToLegacyDec().Quo(math.LegacyNewDec(20))
fivePercent := fivePercentDec.TruncateInt()

// totalFeeMinusBurn = disputeFeeTotal - fivePercent
totalFeeMinusBurnDec := disputeFeeTotal.Sub(fivePercent).ToLegacyDec()
// totalFeeMinusBurn = totalFeeRd1 - fivePercent
totalFeeMinusBurnDec := totalFeeRd1.Sub(fivePercent).ToLegacyDec()

powerReductionDec := math.LegacyNewDecFromInt(layertypes.PowerReduction)

// (fee paid in rd1 / total fee rd 1) * (total fee all rounds - burn)
// (payerFee / totalFeeRd1) * (totalFeeRd1 - burn)
// result scaled by PowerReduction
amtFixed12Dec := payerFeeDec.Mul(totalFeeMinusBurnDec).Mul(powerReductionDec).Quo(totalFeeRd1Dec)

Expand Down
Loading
Loading